feat/proposer-antifragile #31

Merged
Archicratia merged 4 commits from feat/proposer-antifragile into master 2026-01-19 21:30:26 +01:00
7 changed files with 387 additions and 29 deletions

7
.gitignore vendored
View File

@@ -14,8 +14,6 @@ sources/
scripts/
# Backups / fichiers cassés (à garder hors repo)
src/layouts/*.bak*
src/layouts/*.BROKEN*
# Astro generated
.astro/
@@ -23,3 +21,8 @@ src/layouts/*.BROKEN*
# --- allow the apply-ticket tool script to be versioned ---
!scripts/
!scripts/apply-ticket.mjs
# Local backups / crash copies
src/**/*.bak
src/**/*.BROKEN.*
src/**/*.step*-fix.bak

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",
"postbuild": "npx pagefind --site dist",
"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": {
"@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");
})();

View File

@@ -95,9 +95,26 @@
/** @type {"correction"|"fact"|""} */
let kind = "";
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const upsertLine = (text, key, value) => {
const re = new RegExp(`^\\s*${key}\\s*:\\s*.*$`, "mi");
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
// value vide => supprimer la ligne si elle existe
if (!value) {
if (!re.test(text)) return text;
return (
text
.replace(re, "")
.replace(/\n{3,}/g, "\n\n")
.trimEnd() + "\n"
);
}
// remplace si existe
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
// sinon append
const sep = text && !text.endsWith("\n") ? "\n" : "";
return text + sep + `${key}: ${value}\n`;
};
@@ -171,12 +188,16 @@
const cat = btn.getAttribute("data-category") || "";
let body = pending.searchParams.get("body") || "";
if (cat) body = upsertLine(body, "Category", cat);
body = upsertLine(body, "Category", cat);
pending.searchParams.set("body", body);
const u = pending.toString();
dlg.close();
window.open(pending.toString(), "_blank", "noopener,noreferrer");
const w = window.open(u, "_blank", "noopener,noreferrer");
if (!w) window.location.href = u;
});
})();
</script>

View File

@@ -156,29 +156,19 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
if (giteaReady) {
const propose = document.createElement("a");
propose.className = "para-propose";
propose.href = "#";
propose.textContent = "Proposer";
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
propose.setAttribute("data-propose", "1");
propose.addEventListener("click", (e) => {
e.preventDefault();
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
const url = buildIssueURL(p.id, excerpt);
// on stocke l'URL pour le modal
propose.dataset.url = url.toString();
// progressive enhancement : sans JS/modal, href fonctionne.
propose.href = url;
// fallback: si le modal n'existe pas -> ouverture directe
const dlg = document.getElementById("propose-modal");
if (!dlg || typeof dlg.showModal !== "function") {
window.open(propose.dataset.url, "_blank", "noopener,noreferrer");
return;
}
// si modal existe, il va intercepter le click globalement
// donc on ne fait rien ici de plus.
});
// compat : la modal lit data-url en priorité (garde aussi href).
propose.dataset.url = url;
tools.appendChild(propose);
}

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