ci: verify all anchor aliases are injected in dist
Some checks failed
CI / build-and-anchors (push) Failing after 26s

This commit is contained in:
2026-01-23 14:08:03 +01:00
parent 44974a676d
commit 3d4ab82047
2 changed files with 210 additions and 39 deletions

View File

@@ -90,6 +90,11 @@ jobs:
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
git log -1 --oneline git log -1 --oneline
- name: Anchor aliases schema
run: node scripts/check-anchor-aliases.mjs
- name: Verify anchor aliases injected in dist
run: node scripts/verify-anchor-aliases-in-dist.mjs
- name: NPM harden - name: NPM harden
run: | run: |
@@ -109,9 +114,10 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Verify anchor aliases injected
run: node scripts/verify-anchor-aliases-in-dist.mjs
- name: Anchors contract - name: Anchors contract
run: npm run test:anchors run: npm run test:anchors
- name: Anchor aliases schema
run: node scripts/check-anchor-aliases.mjs

View File

@@ -1,63 +1,228 @@
import fs from "node:fs"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
const CWD = process.cwd(); function escapeRegExp(s) {
const ALIASES_PATH = path.join(CWD, "src", "anchors", "anchor-aliases.json"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const DIST_DIR = path.join(CWD, "dist");
if (!fs.existsSync(ALIASES_PATH)) {
console.log(" Pas d'aliases => skip verify.");
process.exit(0);
} }
if (!fs.existsSync(DIST_DIR)) {
console.error("❌ dist/ introuvable. Lance d'abord le build."); function routeToHtmlPath(distDir, route) {
if (typeof route !== "string") throw new Error(`Route must be a string, got ${typeof route}`);
// Normalise: route must be like "/a/b/" or "/"
let r = route.trim();
if (!r.startsWith("/")) r = "/" + r;
if (r !== "/" && !r.endsWith("/")) r = r + "/";
const segments = r.split("/").filter(Boolean); // removes empty
if (segments.length === 0) return path.join(distDir, "index.html");
return path.join(distDir, ...segments, "index.html");
}
function countIdAttr(html, id) {
const re = new RegExp(`\\bid="${escapeRegExp(id)}"`, "g");
let c = 0;
while (re.exec(html)) c++;
return c;
}
function snippetAround(html, idx, beforeLines = 2, afterLines = 4) {
const lines = html.split("\n");
// compute line number
const upto = html.slice(0, Math.max(0, idx));
const lineNo = upto.split("\n").length; // 1-based
const start = Math.max(1, lineNo - beforeLines);
const end = Math.min(lines.length, lineNo + afterLines);
const out = [];
for (let i = start; i <= end; i++) {
out.push(`${String(i).padStart(5, " ")}| ${lines[i - 1]}`);
}
return out.join("\n");
}
function parseArgs(argv) {
const args = {
dist: "dist",
aliases: path.join("src", "anchors", "anchor-aliases.json"),
strict: true,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--dist" && argv[i + 1]) args.dist = argv[++i];
else if (a === "--aliases" && argv[i + 1]) args.aliases = argv[++i];
else if (a === "--non-strict") args.strict = false;
else if (a === "-h" || a === "--help") {
console.log(`Usage:
node scripts/verify-anchor-aliases-in-dist.mjs [--dist dist] [--aliases src/anchors/anchor-aliases.json] [--non-strict]
Checks that every (route, oldId->newId) alias is injected into the built HTML in dist.`);
process.exit(0);
} else {
console.error("Unknown arg:", a);
process.exit(2);
}
}
return args;
}
const { dist, aliases, strict } = parseArgs(process.argv);
const CWD = process.cwd();
const distDir = path.isAbsolute(dist) ? dist : path.join(CWD, dist);
const aliasesPath = path.isAbsolute(aliases) ? aliases : path.join(CWD, aliases);
let data;
try {
data = JSON.parse(await fs.readFile(aliasesPath, "utf8"));
} catch (e) {
console.error(`❌ Cannot read/parse aliases JSON: ${aliasesPath}`);
console.error(e?.message || e);
process.exit(1); process.exit(1);
} }
const aliases = JSON.parse(fs.readFileSync(ALIASES_PATH, "utf8")); if (!data || typeof data !== "object" || Array.isArray(data)) {
console.error("❌ anchor-aliases.json must be an object of { route: { oldId: newId } }");
process.exit(1);
}
let pages = 0;
let aliasesCount = 0;
let checked = 0; let checked = 0;
let failed = 0; const failures = [];
for (const [route, mapping] of Object.entries(aliases)) { for (const [route, mapping] of Object.entries(data)) {
const htmlPath = path.join(DIST_DIR, route.replace(/^\//, ""), "index.html"); pages++;
if (!fs.existsSync(htmlPath)) {
console.error(`❌ HTML introuvable pour route=${route} => ${htmlPath}`); if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
failed++; failures.push({ route, msg: "Mapping must be an object oldId->newId." });
continue;
}
const htmlPath = routeToHtmlPath(distDir, route);
let html;
try {
html = await fs.readFile(htmlPath, "utf8");
} catch (e) {
failures.push({
route,
msg: `Missing built page: ${htmlPath}. Did you run 'npm run build'?`,
});
continue; continue;
} }
const html = fs.readFileSync(htmlPath, "utf8");
for (const [oldId, newId] of Object.entries(mapping)) { for (const [oldId, newId] of Object.entries(mapping)) {
aliasesCount++;
checked++; checked++;
const oldNeedle = `id="${oldId}"`; if (typeof oldId !== "string" || typeof newId !== "string") {
const newNeedle = `id="${newId}"`; failures.push({ route, oldId, newId, htmlPath, msg: "oldId/newId must be strings." });
const oldPos = html.indexOf(oldNeedle);
const newPos = html.indexOf(newNeedle);
if (newPos === -1) {
console.error(`❌ newId absent: route=${route} newId=${newId}`);
failed++;
continue; continue;
} }
if (oldPos === -1) {
console.error(`❌ alias oldId non injecté: route=${route} oldId=${oldId} (newId=${newId})`); const oldCount = countIdAttr(html, oldId);
failed++; const newCount = countIdAttr(html, newId);
if (oldCount === 0) {
failures.push({
route,
oldId,
newId,
htmlPath,
msg: `oldId not found in HTML (expected injected alias span).`,
});
continue; continue;
} }
if (oldPos > newPos) { if (newCount === 0) {
console.error(`❌ alias placé APRÈS newId: route=${route} oldId=${oldId} newId=${newId}`); failures.push({
failed++; route,
oldId,
newId,
htmlPath,
msg: `newId not found in HTML (target missing).`,
});
continue;
}
// Strictness: ensure uniqueness
if (strict && oldCount !== 1) {
failures.push({
route,
oldId,
newId,
htmlPath,
msg: `oldId occurs ${oldCount} times (expected exactly 1).`,
});
continue;
}
if (strict && newCount !== 1) {
failures.push({
route,
oldId,
newId,
htmlPath,
msg: `newId occurs ${newCount} times (expected exactly 1).`,
});
continue;
}
// Require para-alias class on the injected span (contract)
const reAliasSpan = new RegExp(
`<span[^>]*\\bid="${escapeRegExp(oldId)}"[^>]*\\bclass="[^"]*\\bpara-alias\\b[^"]*"[^>]*>\\s*<\\/span>`,
"i"
);
if (!reAliasSpan.test(html)) {
failures.push({
route,
oldId,
newId,
htmlPath,
msg: `Injected alias span exists but does not match expected contract (missing class="...para-alias...").`,
});
continue;
}
// Adjacency: alias span immediately before the element carrying newId
const reAdjacent = new RegExp(
`<span[^>]*\\bid="${escapeRegExp(oldId)}"[^>]*\\bclass="[^"]*\\bpara-alias\\b[^"]*"[^>]*>\\s*<\\/span>\\s*<[^>]*\\bid="${escapeRegExp(
newId
)}"`,
"is"
);
if (!reAdjacent.test(html)) {
const oldIdx = html.indexOf(`id="${oldId}"`);
const newIdx = html.indexOf(`id="${newId}"`);
failures.push({
route,
oldId,
newId,
htmlPath,
msg:
`oldId & newId are present, but alias is NOT immediately before target.\n` +
`--- Context around oldId (line approx) ---\n${snippetAround(html, oldIdx)}\n\n` +
`--- Context around newId (line approx) ---\n${snippetAround(html, newIdx)}\n`,
});
continue; continue;
} }
} }
} }
if (failed) { if (failures.length) {
console.error(`verify-anchor-aliases-in-dist: failed=${failed} checked=${checked}`); console.error(`Alias injection verification FAILED.`);
console.error(`Checked: pages=${pages}, aliases=${aliasesCount}, verified_pairs=${checked}, strict=${strict}`);
console.error("");
for (const f of failures) {
console.error("------------------------------------------------------------");
console.error(`Route: ${f.route}`);
if (f.htmlPath) console.error(`HTML: ${f.htmlPath}`);
if (f.oldId) console.error(`oldId: ${f.oldId}`);
if (f.newId) console.error(`newId: ${f.newId}`);
console.error(`Reason: ${f.msg}`);
}
process.exit(1); process.exit(1);
} }
console.log(`✅ verify-anchor-aliases-in-dist: checked=${checked} (all good)`); console.log(`✅ verify-anchor-aliases-in-dist OK: pages=${pages} aliases=${aliasesCount} strict=${strict}`);