chore(editorial): harden proposer queue and apply-ticket
All checks were successful
SMOKE / smoke (push) Successful in 6s
CI / build-and-anchors (push) Successful in 43s
CI / build-and-anchors (pull_request) Successful in 46s

This commit is contained in:
2026-03-15 23:57:11 +01:00
parent 02c060d239
commit ab6f45ed5c
4 changed files with 571 additions and 110 deletions

View File

@@ -39,7 +39,7 @@ Env (recommandé):
Notes:
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
- Sauvegarde automatique: .tmp/apply-ticket/<fichier>.bak.issue-<N> (uniquement si on écrit)
- Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new.
- Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux.
`);
@@ -89,6 +89,7 @@ const CWD = process.cwd();
const CONTENT_ROOT = path.join(CWD, "src", "content");
const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const BACKUP_ROOT = path.join(CWD, ".tmp", "apply-ticket");
/* -------------------------- utils texte / matching -------------------------- */
@@ -158,6 +159,17 @@ function bestBlockMatchIndex(blocks, targetText) {
return best;
}
function rankedBlockMatches(blocks, targetText, limit = 5) {
return blocks
.map((b, i) => ({
i,
score: scoreText(b, targetText),
excerpt: stripMd(b).slice(0, 140),
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
function splitParagraphBlocks(mdxText) {
const raw = String(mdxText ?? "").replace(/\r\n/g, "\n");
return raw.split(/\n{2,}/);
@@ -612,18 +624,15 @@ async function main() {
const original = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original);
const best = bestBlockMatchIndex(blocks, targetText);
const ranked = rankedBlockMatches(blocks, targetText, 5);
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
const runnerUp = ranked[1] || null;
// seuil de sécurité
// seuil absolu
if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
const ranked = blocks
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
console.error("Top candidates:");
for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
@@ -631,6 +640,20 @@ async function main() {
process.exit(2);
}
// seuil relatif : si le 2e est trop proche du 1er, on refuse aussi
if (runnerUp) {
const ambiguityGap = best.score - runnerUp.score;
if (ambiguityGap < 15) {
console.error("❌ Match ambigu: le meilleur candidat est trop proche du second.");
console.error(`➡️ best=${best.score} / second=${runnerUp.score} / gap=${ambiguityGap}`);
console.error("Top candidates:");
for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
}
process.exit(2);
}
}
const beforeBlock = blocks[best.i];
const afterBlock = proposition.trim();
@@ -651,7 +674,10 @@ async function main() {
}
// backup uniquement si on écrit
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
const relContentFile = path.relative(CWD, contentFile);
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
await fs.mkdir(path.dirname(bakPath), { recursive: true });
if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, original, "utf-8");
}
@@ -684,6 +710,8 @@ async function main() {
}
// garde-fous rapides
run("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD });
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
run("npm", ["run", "test:anchors"], { cwd: CWD });
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
}