ci: support shard annotations in checks + endpoint (pageKey inference)
This commit is contained in:
@@ -117,7 +117,7 @@ async function walk(dir) {
|
|||||||
function inferExpectedFromRel(relNoExt) {
|
function inferExpectedFromRel(relNoExt) {
|
||||||
const parts = relNoExt.split("/").filter(Boolean);
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
const last = parts.at(-1) || "";
|
const last = parts.at(-1) || "";
|
||||||
const isShard = /^p-\d+-/i.test(last);
|
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||||
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||||
const paraId = isShard ? last : null;
|
const paraId = isShard ? last : null;
|
||||||
return { isShard, pageKey, paraId };
|
return { isShard, pageKey, paraId };
|
||||||
@@ -141,7 +141,6 @@ function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (expectedParaId) {
|
if (expectedParaId) {
|
||||||
// invariant shard : exactement 1 clé, celle du filename
|
|
||||||
const keys = Object.keys(doc.paras || {}).map(String);
|
const keys = Object.keys(doc.paras || {}).map(String);
|
||||||
assert(
|
assert(
|
||||||
keys.includes(expectedParaId),
|
keys.includes(expectedParaId),
|
||||||
@@ -165,15 +164,14 @@ async function main() {
|
|||||||
const files = await walk(ANNO_ROOT);
|
const files = await walk(ANNO_ROOT);
|
||||||
|
|
||||||
for (const fp of files) {
|
for (const fp of files) {
|
||||||
const rel = normPath(path.relative(ANNO_ROOT, fp)); // e.g. archicrat-ia/chapitre-4/p-11-...
|
const rel = normPath(path.relative(ANNO_ROOT, fp));
|
||||||
const relNoExt = rel.replace(/\.ya?ml$/i, ""); // no ext
|
const relNoExt = rel.replace(/\.ya?ml$/i, "");
|
||||||
const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt);
|
const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(fp, "utf8");
|
const raw = await fs.readFile(fp, "utf8");
|
||||||
const doc = YAML.parse(raw) || {};
|
const doc = YAML.parse(raw) || {};
|
||||||
|
|
||||||
// ignore non schema:1
|
|
||||||
if (!isObj(doc) || doc.schema !== 1) continue;
|
if (!isObj(doc) || doc.schema !== 1) continue;
|
||||||
|
|
||||||
validateAndNormalizeDoc(
|
validateAndNormalizeDoc(
|
||||||
@@ -209,7 +207,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort paras per page
|
|
||||||
for (const [pageKey, pg] of Object.entries(pages)) {
|
for (const [pageKey, pg] of Object.entries(pages)) {
|
||||||
const keys = Object.keys(pg.paras || {});
|
const keys = Object.keys(pg.paras || {});
|
||||||
keys.sort((a, b) => {
|
keys.sort((a, b) => {
|
||||||
@@ -235,7 +232,6 @@ async function main() {
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
// CI behaviour: if ANY error => fail build
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ async function main() {
|
|||||||
let missing = 0;
|
let missing = 0;
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
|
||||||
|
// Optim: éviter de vérifier 100 fois le même fichier media
|
||||||
|
const seenMedia = new Set(); // src string
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const rel = path.relative(CWD, f).replace(/\\/g, "/");
|
const rel = path.relative(CWD, f).replace(/\\/g, "/");
|
||||||
const raw = await fs.readFile(f, "utf8");
|
const raw = await fs.readFile(f, "utf8");
|
||||||
@@ -70,6 +73,10 @@ async function main() {
|
|||||||
const src = String(m?.src || "");
|
const src = String(m?.src || "");
|
||||||
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
|
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
|
||||||
|
|
||||||
|
// dédupe
|
||||||
|
if (seenMedia.has(src)) continue;
|
||||||
|
seenMedia.add(src);
|
||||||
|
|
||||||
checked++;
|
checked++;
|
||||||
const p = toPublicPathFromUrl(src);
|
const p = toPublicPathFromUrl(src);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
@@ -94,4 +101,4 @@ async function main() {
|
|||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error("FAIL: check-annotations-media crashed:", e);
|
console.error("FAIL: check-annotations-media crashed:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -27,11 +27,6 @@ function escRe(s) {
|
|||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPageKeyFromFile(fileAbs) {
|
|
||||||
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
|
||||||
return rel.replace(/\.(ya?ml|json)$/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageKey(s) {
|
function normalizePageKey(s) {
|
||||||
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
@@ -40,6 +35,31 @@ function isPlainObject(x) {
|
|||||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isParaId(s) {
|
||||||
|
return /^p-\d+-/i.test(String(s || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supporte:
|
||||||
|
* - monolith: src/annotations/<pageKey>.yml -> pageKey = rel sans ext
|
||||||
|
* - shard : src/annotations/<pageKey>/<paraId>.yml -> pageKey = dirname(rel), paraId = basename
|
||||||
|
*
|
||||||
|
* shard seulement si le fichier est dans un sous-dossier (anti cas pathologique).
|
||||||
|
*/
|
||||||
|
function inferFromFile(fileAbs) {
|
||||||
|
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
||||||
|
const relNoExt = rel.replace(/\.(ya?ml|json)$/i, "");
|
||||||
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
|
const base = parts[parts.length - 1] || "";
|
||||||
|
const dirParts = parts.slice(0, -1);
|
||||||
|
|
||||||
|
const isShard = dirParts.length > 0 && isParaId(base);
|
||||||
|
const pageKey = isShard ? dirParts.join("/") : relNoExt;
|
||||||
|
const paraId = isShard ? base : "";
|
||||||
|
|
||||||
|
return { pageKey: normalizePageKey(pageKey), paraId };
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAliases() {
|
async function loadAliases() {
|
||||||
if (!(await exists(ALIASES_PATH))) return {};
|
if (!(await exists(ALIASES_PATH))) return {};
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +103,11 @@ async function main() {
|
|||||||
const aliases = await loadAliases();
|
const aliases = await loadAliases();
|
||||||
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
||||||
|
|
||||||
let pages = 0;
|
// perf: cache HTML par page (shards = beaucoup de fichiers pour 1 page)
|
||||||
|
const htmlCache = new Map(); // pageKey -> html
|
||||||
|
const missingDistPage = new Set(); // pageKey
|
||||||
|
|
||||||
|
let pagesSeen = new Set();
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
const notes = [];
|
const notes = [];
|
||||||
@@ -107,7 +131,7 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageKey = normalizePageKey(inferPageKeyFromFile(f));
|
const { pageKey, paraId: shardParaId } = inferFromFile(f);
|
||||||
|
|
||||||
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
|
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
|
||||||
failures++;
|
failures++;
|
||||||
@@ -121,20 +145,44 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shard invariant (fort) : doit contenir paras[paraId]
|
||||||
|
if (shardParaId) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(doc.paras, shardParaId)) {
|
||||||
|
failures++;
|
||||||
|
notes.push(`- SHARD MISMATCH: ${rel} (expected paras["${shardParaId}"] present)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// si extras -> warning (non destructif)
|
||||||
|
const keys = Object.keys(doc.paras);
|
||||||
|
if (!(keys.length === 1 && keys[0] === shardParaId)) {
|
||||||
|
notes.push(`- WARN shard has extra paras: ${rel} (expected only "${shardParaId}", got ${keys.join(", ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesSeen.add(pageKey);
|
||||||
|
|
||||||
const distFile = path.join(DIST_DIR, pageKey, "index.html");
|
const distFile = path.join(DIST_DIR, pageKey, "index.html");
|
||||||
if (!(await exists(distFile))) {
|
if (!(await exists(distFile))) {
|
||||||
failures++;
|
if (!missingDistPage.has(pageKey)) {
|
||||||
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
missingDistPage.add(pageKey);
|
||||||
|
failures++;
|
||||||
|
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
||||||
|
} else {
|
||||||
|
notes.push(`- WARN missing page already reported: dist/${pageKey}/index.html (from ${rel})`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
pages++;
|
let html = htmlCache.get(pageKey);
|
||||||
const html = await fs.readFile(distFile, "utf8");
|
if (!html) {
|
||||||
|
html = await fs.readFile(distFile, "utf8");
|
||||||
|
htmlCache.set(pageKey, html);
|
||||||
|
}
|
||||||
|
|
||||||
for (const paraId of Object.keys(doc.paras)) {
|
for (const paraId of Object.keys(doc.paras)) {
|
||||||
checked++;
|
checked++;
|
||||||
|
|
||||||
if (!/^p-\d+-/i.test(paraId)) {
|
if (!isParaId(paraId)) {
|
||||||
failures++;
|
failures++;
|
||||||
notes.push(`- INVALID ID: ${rel} (${paraId})`);
|
notes.push(`- INVALID ID: ${rel} (${paraId})`);
|
||||||
continue;
|
continue;
|
||||||
@@ -158,6 +206,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warns = notes.filter((x) => x.startsWith("- WARN"));
|
const warns = notes.filter((x) => x.startsWith("- WARN"));
|
||||||
|
const pages = pagesSeen.size;
|
||||||
|
|
||||||
if (failures > 0) {
|
if (failures > 0) {
|
||||||
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);
|
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);
|
||||||
@@ -172,4 +221,4 @@ async function main() {
|
|||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error("FAIL: annotations check crashed:", e);
|
console.error("FAIL: annotations check crashed:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -57,25 +57,24 @@ function deepMergeEntry(dst: any, src: any) {
|
|||||||
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||||
|
|
||||||
if (isObj(v)) {
|
if (isObj(v)) {
|
||||||
if (!isObj(dst[k])) dst[k] = {};
|
if (!isObj((dst as any)[k])) (dst as any)[k] = {};
|
||||||
deepMergeEntry(dst[k], v);
|
deepMergeEntry((dst as any)[k], v);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArr(v)) {
|
if (isArr(v)) {
|
||||||
const cur = isArr(dst[k]) ? dst[k] : [];
|
const cur = isArr((dst as any)[k]) ? (dst as any)[k] : [];
|
||||||
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
||||||
const out = [...cur];
|
const out = [...cur];
|
||||||
for (const it of v) {
|
for (const it of v) {
|
||||||
const s = JSON.stringify(it);
|
const s = JSON.stringify(it);
|
||||||
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||||
}
|
}
|
||||||
dst[k] = out;
|
(dst as any)[k] = out;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// scalar: set only if missing/empty
|
if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v;
|
||||||
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ async function walk(dir: string): Promise<string[]> {
|
|||||||
function inferExpected(relNoExt: string) {
|
function inferExpected(relNoExt: string) {
|
||||||
const parts = relNoExt.split("/").filter(Boolean);
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
const last = parts.at(-1) || "";
|
const last = parts.at(-1) || "";
|
||||||
const isShard = /^p-\d+-/i.test(last);
|
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||||
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||||
const paraId = isShard ? last : null;
|
const paraId = isShard ? last : null;
|
||||||
return { isShard, pageKey, paraId };
|
return { isShard, pageKey, paraId };
|
||||||
@@ -136,6 +135,12 @@ export const GET: APIRoute = async () => {
|
|||||||
if (!(paraId in doc.paras)) {
|
if (!(paraId in doc.paras)) {
|
||||||
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
||||||
}
|
}
|
||||||
|
// ✅ invariant aligné avec build-annotations-index
|
||||||
|
const keys = Object.keys(doc.paras).map(String);
|
||||||
|
if (!(keys.length === 1 && keys[0] === paraId)) {
|
||||||
|
throw new Error(`shard invariant violated: shard must contain ONLY paras["${paraId}"] (got: ${keys.join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
const entry = doc.paras[paraId];
|
const entry = doc.paras[paraId];
|
||||||
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||||
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||||
@@ -159,8 +164,7 @@ export const GET: APIRoute = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort paras
|
for (const [pk, pg] of Object.entries(pages)) {
|
||||||
for (const [pageKey, pg] of Object.entries(pages)) {
|
|
||||||
const keys = Object.keys(pg.paras || {});
|
const keys = Object.keys(pg.paras || {});
|
||||||
keys.sort((a, b) => {
|
keys.sort((a, b) => {
|
||||||
const ia = paraNum(a);
|
const ia = paraNum(a);
|
||||||
@@ -185,7 +189,6 @@ export const GET: APIRoute = async () => {
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔥 comportement “pro CI” : si erreurs => build fail
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user