155 lines
4.4 KiB
JavaScript
Executable File
155 lines
4.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import fs from "node:fs/promises";
|
|
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");
|
|
|
|
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 : .reading p[id^="p-"]
|
|
function extractIds(html) {
|
|
if (!html.includes('class="reading"')) return [];
|
|
const ids = [];
|
|
const re = /<p\b[^>]*\sid="(p-[^"]+)"/g;
|
|
let m;
|
|
while ((m = re.exec(html))) ids.push(m[1]);
|
|
|
|
const seen = new Set();
|
|
const uniq = [];
|
|
for (const id of ids) {
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
uniq.push(id);
|
|
}
|
|
return uniq;
|
|
}
|
|
|
|
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, "utf-8");
|
|
const ids = extractIds(html);
|
|
if (ids.length === 0) continue;
|
|
snap[rel] = ids;
|
|
}
|
|
|
|
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, "utf-8");
|
|
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", "utf-8");
|
|
}
|
|
|
|
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;
|
|
|
|
if (!prevIds && curIds) {
|
|
console.log(`+ PAGE ${p} (new) ids=${curIds.length}`);
|
|
continue;
|
|
}
|
|
if (prevIds && !curIds) {
|
|
console.log(`- PAGE ${p} (missing now) prevIds=${prevIds.length}`);
|
|
failed = true;
|
|
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 line =
|
|
`~ ${p} prev=${prevIds.length} now=${curIds.length}` +
|
|
` +${added.length} -${removed.length} churn=${pct(churn)}`;
|
|
|
|
console.log(line);
|
|
|
|
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 && removed.length / prevN > 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");
|
|
})();
|