From 93306f360d1f9c557addb4e493b7fd028c2144f2 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 16 Mar 2026 11:48:08 +0100 Subject: [PATCH] fix(editorial): preserve frontmatter in apply-ticket --- scripts/apply-ticket.mjs | 159 +++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 39 deletions(-) diff --git a/scripts/apply-ticket.mjs b/scripts/apply-ticket.mjs index f4775df..08872b4 100644 --- a/scripts/apply-ticket.mjs +++ b/scripts/apply-ticket.mjs @@ -9,8 +9,9 @@ import { spawnSync } from "node:child_process"; * * Conçu pour: * - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition - * - retrouver le bon paragraphe dans le .mdx + * - retrouver le bon paragraphe dans le .mdx/.md * - remplacer proprement + * - ne JAMAIS toucher au frontmatter * - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json * - optionnel: committer automatiquement * - optionnel: fermer le ticket (après commit) @@ -137,28 +138,17 @@ function scoreText(candidate, targetText) { let hit = 0; for (const w of tgtSet) if (blkSet.has(w)) hit++; - // Bonus si un long préfixe ressemble const tgtNorm = normalizeText(stripMd(targetText)); const blkNorm = normalizeText(stripMd(candidate)); const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length)); const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0; - // Ratio bonus (0..100) const ratio = hit / Math.max(1, tgtSet.size); const ratioBonus = Math.round(ratio * 100); return prefixBonus + hit + ratioBonus; } -function bestBlockMatchIndex(blocks, targetText) { - let best = { i: -1, score: -1 }; - for (let i = 0; i < blocks.length; i++) { - const sc = scoreText(blocks[i], targetText); - if (sc > best.score) best = { i, score: sc }; - } - return best; -} - function rankedBlockMatches(blocks, targetText, limit = 5) { return blocks .map((b, i) => ({ @@ -170,11 +160,6 @@ function rankedBlockMatches(blocks, targetText, limit = 5) { .slice(0, limit); } -function splitParagraphBlocks(mdxText) { - const raw = String(mdxText ?? "").replace(/\r\n/g, "\n"); - return raw.split(/\n{2,}/); -} - function isLikelyExcerpt(s) { const t = String(s || "").trim(); if (!t) return true; @@ -184,6 +169,89 @@ function isLikelyExcerpt(s) { return false; } +/* --------------------------- frontmatter / structure ------------------------ */ + +function normalizeNewlines(s) { + return String(s ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); +} + +function splitMdxFrontmatter(src) { + const text = normalizeNewlines(src); + const m = text.match(/^---\n[\s\S]*?\n---\n?/); + + if (!m) { + return { + hasFrontmatter: false, + frontmatter: "", + body: text, + }; + } + + const frontmatter = m[0]; + const body = text.slice(frontmatter.length); + + return { + hasFrontmatter: true, + frontmatter, + body, + }; +} + +function joinMdxFrontmatter(frontmatter, body) { + if (!frontmatter) return String(body ?? ""); + return String(frontmatter) + String(body ?? ""); +} + +function assertFrontmatterIntegrity({ hadFrontmatter, originalFrontmatter, finalText, filePath }) { + if (!hadFrontmatter) return; + + const text = normalizeNewlines(finalText); + + if (!text.startsWith("---\n")) { + throw new Error(`Frontmatter perdu pendant la mise à jour de ${filePath}`); + } + + if (!text.startsWith(originalFrontmatter)) { + throw new Error(`Frontmatter altéré pendant la mise à jour de ${filePath}`); + } +} + +function splitParagraphBlocksPreserve(bodyText) { + const text = normalizeNewlines(bodyText); + + if (!text) { + return { blocks: [], separators: [] }; + } + + const blocks = []; + const separators = []; + + const re = /(\n{2,})/g; + let last = 0; + let m; + + while ((m = re.exec(text))) { + blocks.push(text.slice(last, m.index)); + separators.push(m[1]); + last = m.index + m[1].length; + } + + blocks.push(text.slice(last)); + + return { blocks, separators }; +} + +function joinParagraphBlocksPreserve(blocks, separators) { + if (!Array.isArray(blocks) || blocks.length === 0) return ""; + + let out = ""; + for (let i = 0; i < blocks.length; i++) { + out += blocks[i]; + if (i < separators.length) out += separators[i]; + } + return out; +} + /* ------------------------------ utils système ------------------------------ */ function run(cmd, args, opts = {}) { @@ -263,7 +331,9 @@ function pickSection(body, markers) { .map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) })) .filter((x) => x.i >= 0) .sort((a, b) => a.i - b.i)[0]; + if (!idx) return ""; + const start = idx.i + idx.m.length; const tail = text.slice(start); @@ -278,11 +348,13 @@ function pickSection(body, markers) { "\n## Proposition", "\n## Problème", ]; + let end = tail.length; for (const s of stops) { const j = tail.toLowerCase().indexOf(s.toLowerCase()); if (j >= 0 && j < end) end = j; } + return tail.slice(0, end).trim(); } @@ -310,8 +382,6 @@ function extractAnchorIdAnywhere(text) { function extractCheminFromAnyUrl(text) { const s = String(text || ""); - // Exemple: http://localhost:4321/archicratie/prologue/#p-3-xxxx - // ou: /archicratie/prologue/#p-3-xxxx const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i); return m ? m[1] : ""; } @@ -412,7 +482,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) { headers: { Authorization: `token ${token}`, Accept: "application/json", - "User-Agent": "archicratie-apply-ticket/2.0", + "User-Agent": "archicratie-apply-ticket/2.1", }, }); if (!res.ok) { @@ -428,7 +498,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment Authorization: `token ${token}`, Accept: "application/json", "Content-Type": "application/json", - "User-Agent": "archicratie-apply-ticket/2.0", + "User-Agent": "archicratie-apply-ticket/2.1", }; if (comment) { @@ -437,7 +507,11 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment } const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`; - const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) }); + const res = await fetch(url, { + method: "PATCH", + headers, + body: JSON.stringify({ state: "closed" }), + }); if (!res.ok) { const t = await res.text().catch(() => ""); @@ -541,10 +615,9 @@ async function main() { console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`); const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum }); - // Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial) if (issue?.pull_request) { console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`); - console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`); + console.error("➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro."); process.exit(2); } @@ -565,7 +638,6 @@ async function main() { ancre = (ancre || "").trim(); if (ancre.startsWith("#")) ancre = ancre.slice(1); - // fallback si ticket mal formé if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body); chemin = normalizeChemin(chemin); @@ -604,7 +676,6 @@ async function main() { const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html"); await ensureBuildIfNeeded(distHtmlPath); - // Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable let targetText = texteActuel; let distText = ""; @@ -621,18 +692,24 @@ async function main() { throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html)."); } - const original = await fs.readFile(contentFile, "utf-8"); - const blocks = splitParagraphBlocks(original); + const originalRaw = await fs.readFile(contentFile, "utf-8"); + const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw); + + const split = splitParagraphBlocksPreserve(originalBody); + const blocks = split.blocks; + const separators = split.separators; + + if (!blocks.length) { + throw new Error(`Aucun bloc éditorial exploitable dans ${path.relative(CWD, contentFile)}`); + } const ranked = rankedBlockMatches(blocks, targetText, 5); const best = ranked[0] || { i: -1, score: -1, excerpt: "" }; const runnerUp = ranked[1] || null; - // 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)'.`); - console.error("Top candidates:"); for (const r of ranked) { console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`); @@ -640,7 +717,6 @@ 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) { @@ -659,7 +735,16 @@ async function main() { const nextBlocks = blocks.slice(); nextBlocks[best.i] = afterBlock; - const updated = nextBlocks.join("\n\n"); + + const updatedBody = joinParagraphBlocksPreserve(nextBlocks, separators); + const updatedRaw = joinMdxFrontmatter(frontmatter, updatedBody); + + assertFrontmatterIntegrity({ + hadFrontmatter: hasFrontmatter, + originalFrontmatter: frontmatter, + finalText: updatedRaw, + filePath: path.relative(CWD, contentFile), + }); console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`); @@ -673,16 +758,15 @@ async function main() { return; } - // backup uniquement si on écrit 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"); + await fs.writeFile(bakPath, originalRaw, "utf-8"); } - await fs.writeFile(contentFile, updated, "utf-8"); + await fs.writeFile(contentFile, updatedRaw, "utf-8"); console.log("✅ Applied."); let aliasChanged = false; @@ -703,13 +787,11 @@ async function main() { if (aliasChanged) { console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`); - // MàJ dist sans rebuild complet (inject seulement) run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD }); } else { console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`); } - // 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 }); @@ -741,7 +823,6 @@ async function main() { return; } - // mode manuel console.log("Next (manuel) :"); console.log(` git diff -- ${path.relative(CWD, contentFile)}`); console.log( @@ -758,4 +839,4 @@ async function main() { main().catch((e) => { console.error("💥", e?.message || e); process.exit(1); -}); +}); \ No newline at end of file -- 2.49.1