Anchors: contract + baseline + churn test
This commit is contained in:
30
docs/anchors.md
Normal file
30
docs/anchors.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Contrat des ancres (paragraphes opposables)
|
||||||
|
|
||||||
|
## Source de vérité du sélecteur
|
||||||
|
Le site garantit la citabilité des paragraphes via des IDs injectés sur les balises `<p>`.
|
||||||
|
|
||||||
|
**Sélecteur contractuel :**
|
||||||
|
- `.reading p[id^="p-"]`
|
||||||
|
|
||||||
|
Tout outillage (scripts, tests, docs) doit utiliser ce sélecteur comme référence.
|
||||||
|
|
||||||
|
## Ce que le test vérifie
|
||||||
|
Le test compare, page par page, la liste des IDs de paragraphes présents dans `dist/` contre une baseline versionnée.
|
||||||
|
|
||||||
|
- Ajouts d’IDs : généralement OK (nouveaux paragraphes).
|
||||||
|
- Suppressions / churn élevé : alerte (risque de casser des citations existantes).
|
||||||
|
|
||||||
|
## Fichier baseline
|
||||||
|
- `tests/anchors-baseline.json`
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
1) Générer / mettre à jour la baseline (cas intentionnel) :
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run test:anchors:update`
|
||||||
|
|
||||||
|
2) Vérifier sans changer la baseline (cas normal) :
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run test:anchors`
|
||||||
|
|
||||||
|
## Politique d’échec (pragmatique)
|
||||||
|
Le test échoue si le churn d’une page dépasse un seuil (défaut : 20%) sur une page “suffisamment grande”.
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"postbuild": "npx pagefind --site dist",
|
"postbuild": "npx pagefind --site dist",
|
||||||
"import": "node scripts/import-docx.mjs",
|
"import": "node scripts/import-docx.mjs",
|
||||||
"apply:ticket": "node scripts/apply-ticket.mjs"
|
"apply:ticket": "node scripts/apply-ticket.mjs",
|
||||||
|
"test:anchors": "node scripts/check-anchors.mjs",
|
||||||
|
"test:anchors:update": "node scripts/check-anchors.mjs --update"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.13",
|
"@astrojs/mdx": "^4.3.13",
|
||||||
|
|||||||
154
scripts/check-anchors.mjs
Executable file
154
scripts/check-anchors.mjs
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/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");
|
||||||
|
})();
|
||||||
158
tests/anchors-baseline.json
Normal file
158
tests/anchors-baseline.json
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"archicratie/00-demarrage/index.html": [
|
||||||
|
"p-0-d64c1c39",
|
||||||
|
"p-1-3f750540"
|
||||||
|
],
|
||||||
|
"archicratie/prologue/index.html": [
|
||||||
|
"p-0-d7974f88",
|
||||||
|
"p-1-2ef25f29",
|
||||||
|
"p-2-edb49e0a",
|
||||||
|
"p-3-76df8102",
|
||||||
|
"p-4-8ed4f807",
|
||||||
|
"p-5-85126fa5",
|
||||||
|
"p-6-3515039d",
|
||||||
|
"p-7-64a0ca9c",
|
||||||
|
"p-8-e7075fe3",
|
||||||
|
"p-9-5ff70fb7",
|
||||||
|
"p-10-e250e810",
|
||||||
|
"p-11-594bf307",
|
||||||
|
"p-12-a646a8f5",
|
||||||
|
"p-13-4ed7a199",
|
||||||
|
"p-14-edcf3420",
|
||||||
|
"p-15-0f64ad28",
|
||||||
|
"p-16-1318aeab",
|
||||||
|
"p-17-b8c5bf21",
|
||||||
|
"p-18-d38d4e1a",
|
||||||
|
"p-19-b9c1d59c",
|
||||||
|
"p-20-6c9b76cc",
|
||||||
|
"p-21-86372499",
|
||||||
|
"p-22-a416d473",
|
||||||
|
"p-23-d91a7b78",
|
||||||
|
"p-24-c44cc8ac",
|
||||||
|
"p-25-a182772f",
|
||||||
|
"p-26-c6b8b1f0",
|
||||||
|
"p-27-aad0efa5",
|
||||||
|
"p-28-464ce8ad",
|
||||||
|
"p-29-49e3d657",
|
||||||
|
"p-30-4224db6b",
|
||||||
|
"p-31-3248a592",
|
||||||
|
"p-32-b51a5980",
|
||||||
|
"p-33-dde26331",
|
||||||
|
"p-34-a110f27b",
|
||||||
|
"p-35-87338781",
|
||||||
|
"p-36-e68a28f9",
|
||||||
|
"p-37-cb0c66a4",
|
||||||
|
"p-38-1187ae85",
|
||||||
|
"p-39-a127b13e",
|
||||||
|
"p-40-43fc21e2",
|
||||||
|
"p-41-b8f4b969",
|
||||||
|
"p-42-0df88bcf",
|
||||||
|
"p-43-83309ffb",
|
||||||
|
"p-44-5fc0151c",
|
||||||
|
"p-45-6e70bc79",
|
||||||
|
"p-46-418f752d",
|
||||||
|
"p-47-73753452",
|
||||||
|
"p-48-e16b3487",
|
||||||
|
"p-49-57bb6ca6",
|
||||||
|
"p-50-ac1da1ad",
|
||||||
|
"p-51-a0462786",
|
||||||
|
"p-52-fd68db68",
|
||||||
|
"p-53-f498e04d",
|
||||||
|
"p-54-46eee1df",
|
||||||
|
"p-55-57231169",
|
||||||
|
"p-56-fbac6804",
|
||||||
|
"p-57-6adbbd7a",
|
||||||
|
"p-58-d9b19239",
|
||||||
|
"p-59-5a36bcd3",
|
||||||
|
"p-60-99c77374",
|
||||||
|
"p-61-1ed02f1a",
|
||||||
|
"p-62-1b9ed778",
|
||||||
|
"p-63-37fe9d27",
|
||||||
|
"p-64-62cbf51a",
|
||||||
|
"p-65-6c959f55",
|
||||||
|
"p-66-33a0759c",
|
||||||
|
"p-67-295fd24b",
|
||||||
|
"p-68-77cfd63b",
|
||||||
|
"p-69-9842c9db",
|
||||||
|
"p-70-ccf6f44e",
|
||||||
|
"p-71-48838687",
|
||||||
|
"p-72-25f521c5",
|
||||||
|
"p-73-d77db78d",
|
||||||
|
"p-74-52fda87a",
|
||||||
|
"p-75-fca128f1",
|
||||||
|
"p-76-e1475139",
|
||||||
|
"p-77-b3b1ff9c",
|
||||||
|
"p-78-b50a082d",
|
||||||
|
"p-79-0d4b92fc",
|
||||||
|
"p-80-9063c5e5",
|
||||||
|
"p-81-4b8c3114",
|
||||||
|
"p-82-98c28b00",
|
||||||
|
"p-83-09f4bae4",
|
||||||
|
"p-84-579dd8fd",
|
||||||
|
"p-85-2cc85f83",
|
||||||
|
"p-86-a6d8e7dd",
|
||||||
|
"p-87-4c7a5312",
|
||||||
|
"p-88-5eaeefbe",
|
||||||
|
"p-89-58f5046e",
|
||||||
|
"p-90-e8513d94",
|
||||||
|
"p-91-548c5ac8",
|
||||||
|
"p-92-180088f6",
|
||||||
|
"p-93-2a566bda",
|
||||||
|
"p-94-a4144a09",
|
||||||
|
"p-95-bb048846",
|
||||||
|
"p-96-f29c6d75",
|
||||||
|
"p-97-f210ef3e",
|
||||||
|
"p-98-35026cf3",
|
||||||
|
"p-99-11f4ca97",
|
||||||
|
"p-100-b90aa309",
|
||||||
|
"p-101-3d387c8e",
|
||||||
|
"p-102-0e8bfa53",
|
||||||
|
"p-103-be2c9a18",
|
||||||
|
"p-104-fedb49aa",
|
||||||
|
"p-105-176b0d9a",
|
||||||
|
"p-106-171d22ea",
|
||||||
|
"p-107-6f512b92",
|
||||||
|
"p-108-ea9a6441",
|
||||||
|
"p-109-973d0054",
|
||||||
|
"p-110-15ff6f67",
|
||||||
|
"p-111-42154a6b",
|
||||||
|
"p-112-7bfba774",
|
||||||
|
"p-113-cce5b54c",
|
||||||
|
"p-114-8d6d3f62",
|
||||||
|
"p-115-9b893988",
|
||||||
|
"p-116-5380dfcc",
|
||||||
|
"p-117-de90d916",
|
||||||
|
"p-118-3ab98e03",
|
||||||
|
"p-119-485048ef",
|
||||||
|
"p-120-207769f8",
|
||||||
|
"p-121-a3e8441a",
|
||||||
|
"p-122-8e9260a2",
|
||||||
|
"p-123-fd1cc033",
|
||||||
|
"p-124-0d841a3f",
|
||||||
|
"p-125-6ee0b4c6",
|
||||||
|
"p-126-da0b1447",
|
||||||
|
"p-127-55ac00d4",
|
||||||
|
"p-128-a0a6ccfa",
|
||||||
|
"p-129-6fe15281",
|
||||||
|
"p-130-956df10b",
|
||||||
|
"p-131-73c3222c",
|
||||||
|
"p-132-9571e24d",
|
||||||
|
"p-133-6a32a02a",
|
||||||
|
"p-134-358f5875",
|
||||||
|
"p-135-c19330ce",
|
||||||
|
"p-136-17f1cf51",
|
||||||
|
"p-137-d8f1539e"
|
||||||
|
],
|
||||||
|
"atlas/00-demarrage/index.html": [
|
||||||
|
"p-0-97681330"
|
||||||
|
],
|
||||||
|
"glossaire/archicratie/index.html": [
|
||||||
|
"p-0-942a2447"
|
||||||
|
],
|
||||||
|
"ia/00-demarrage/index.html": [
|
||||||
|
"p-0-e2fcdd67"
|
||||||
|
],
|
||||||
|
"traite/00-demarrage/index.html": [
|
||||||
|
"p-0-5abe0849"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user