feat/proposer-antifragile #31

Merged
Archicratia merged 4 commits from feat/proposer-antifragile into master 2026-01-19 21:30:26 +01:00
4 changed files with 345 additions and 1 deletions
Showing only changes of commit 3919827824 - Show all commits

30
docs/anchors.md Normal file
View 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 dIDs : 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 dune page dépasse un seuil (défaut : 20%) sur une page “suffisamment grande”.

View File

@@ -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
View 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
View 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"
]
}