Files
archicratie-edition/scripts/check-anchors.mjs
archicratia 60d88939b0
All checks were successful
CI / build-and-anchors (push) Successful in 1m25s
SMOKE / smoke (push) Successful in 11s
CI / build-and-anchors (pull_request) Successful in 1m20s
Seed from NAS prod snapshot 20260130-190531
2026-01-31 10:51:38 +00:00

195 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import fssync from "node:fs";
import path from "node:path";
const args = new Set(process.argv.slice(2));
const getArg = (name, fallback = null) => {
const i = process.argv.indexOf(name);
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
return fallback;
};
const DIST_DIR = getArg("--dist", "dist");
const BASELINE = getArg("--baseline", path.join("tests", "anchors-baseline.json"));
const UPDATE = args.has("--update");
// Ex: 0.2 => 20%
const THRESHOLD = Number(getArg("--threshold", process.env.ANCHORS_THRESHOLD ?? "0.2"));
const MIN_PREV = Number(getArg("--min-prev", process.env.ANCHORS_MIN_PREV ?? "10"));
const pct = (x) => (Math.round(x * 1000) / 10).toFixed(1) + "%";
async function walk(dir) {
const out = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const ent of entries) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) out.push(...(await walk(p)));
else if (ent.isFile() && ent.name.endsWith(".html")) out.push(p);
}
return out;
}
// Contrat :
// - paragraphes citables : .reading p[id^="p-"]
// - alias web-natifs : .reading span.para-alias[id^="p-"]
function extractIds(html) {
if (!html.includes('class="reading"')) return [];
const ids = [];
let m;
// 1) IDs principaux (paragraphes)
const reP = /<p\b[^>]*\sid="(p-[^"]+)"/g;
while ((m = reP.exec(html))) ids.push(m[1]);
// 2) IDs alias (spans injectés)
// cas A : id="..." avant class="...para-alias..."
const reA1 = /<span\b[^>]*\bid="(p-[^"]+)"[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"/g;
while ((m = reA1.exec(html))) ids.push(m[1]);
// cas B : class="...para-alias..." avant id="..."
const reA2 = /<span\b[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"[^>]*\bid="(p-[^"]+)"/g;
while ((m = reA2.exec(html))) ids.push(m[1]);
// Dé-doublonnage (on garde un ordre stable)
const seen = new Set();
const uniq = [];
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
uniq.push(id);
}
return uniq;
}
function loadAllowMissing() {
const p = path.resolve("scripts/anchors-allow-missing.json");
if (!fssync.existsSync(p)) return new Set();
const raw = fssync.readFileSync(p, "utf8").trim();
if (!raw) return new Set();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) throw new Error("anchors-allow-missing.json must be an array");
return new Set(arr.map(String));
}
const ALLOW_MISSING = loadAllowMissing();
async function buildSnapshot() {
const absDist = path.resolve(DIST_DIR);
const files = await walk(absDist);
const snap = {};
for (const f of files) {
const rel = path.relative(absDist, f).replace(/\\/g, "/");
const html = await fs.readFile(f, "utf8");
const ids = extractIds(html);
if (ids.length === 0) continue;
snap[rel] = ids;
}
// ordre stable
const ordered = {};
for (const k of Object.keys(snap).sort()) ordered[k] = snap[k];
return ordered;
}
async function readJson(p) {
const s = await fs.readFile(p, "utf8");
return JSON.parse(s);
}
async function writeJson(p, obj) {
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, JSON.stringify(obj, null, 2) + "\n", "utf8");
}
function diffPage(prevIds, curIds) {
const prev = new Set(prevIds);
const cur = new Set(curIds);
const added = curIds.filter((x) => !prev.has(x));
const removed = prevIds.filter((x) => !cur.has(x));
return { added, removed };
}
(async () => {
const snap = await buildSnapshot();
if (UPDATE) {
await writeJson(BASELINE, snap);
const pages = Object.keys(snap).length;
const total = Object.values(snap).reduce((a, xs) => a + xs.length, 0);
console.log(`OK baseline updated -> ${BASELINE}`);
console.log(`Pages: ${pages}, Total paragraph IDs: ${total}`);
process.exit(0);
}
let base;
try {
base = await readJson(BASELINE);
} catch {
console.error(`Baseline missing: ${BASELINE}`);
console.error(`Run: node scripts/check-anchors.mjs --update`);
process.exit(2);
}
const allPages = new Set([...Object.keys(base), ...Object.keys(snap)]);
const pages = Array.from(allPages).sort();
let failed = false;
let changedPages = 0;
for (const p of pages) {
const prevIds = base[p] || null;
const curIds = snap[p] || null;
// page nouvelle
if (!prevIds && curIds) {
console.log(`+ PAGE ${p} (new) ids=${curIds.length}`);
continue;
}
// page supprimée
if (prevIds && !curIds) {
if (ALLOW_MISSING.has(p)) {
console.log(`~ PAGE ${p} (missing now) ✅ allowed prevIds=${prevIds.length}`);
continue;
}
console.log(`- PAGE ${p} (missing now) prevIds=${prevIds.length}`);
failed = true;
continue;
}
// (sécurité) rien avant / rien maintenant
if (!prevIds && !curIds) continue;
const { added, removed } = diffPage(prevIds, curIds);
if (added.length === 0 && removed.length === 0) continue;
changedPages += 1;
const prevN = prevIds.length || 1;
const churn = (added.length + removed.length) / prevN;
const removedRatio = removed.length / prevN;
console.log(
`~ ${p} prev=${prevIds.length} now=${curIds.length}` +
` +${added.length} -${removed.length} churn=${pct(churn)}`
);
if (removed.length) {
console.log(` removed: ${removed.slice(0, 20).join(", ")}${removed.length > 20 ? " …" : ""}`);
}
if (prevIds.length >= MIN_PREV && churn > THRESHOLD) failed = true;
if (prevIds.length >= MIN_PREV && removedRatio > THRESHOLD) failed = true;
}
console.log(`\nSummary: pages compared=${pages.length}, pages changed=${changedPages}`);
if (failed) {
console.error(`FAIL: anchor churn above threshold (threshold=${pct(THRESHOLD)} minPrev=${MIN_PREV})`);
process.exit(1);
}
console.log("OK: anchors stable within threshold");
})();