fix(editorial): preserve frontmatter in apply-ticket
This commit is contained in:
@@ -9,8 +9,9 @@ import { spawnSync } from "node:child_process";
|
|||||||
*
|
*
|
||||||
* Conçu pour:
|
* Conçu pour:
|
||||||
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
|
* - 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
|
* - remplacer proprement
|
||||||
|
* - ne JAMAIS toucher au frontmatter
|
||||||
* - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
|
* - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
|
||||||
* - optionnel: committer automatiquement
|
* - optionnel: committer automatiquement
|
||||||
* - optionnel: fermer le ticket (après commit)
|
* - optionnel: fermer le ticket (après commit)
|
||||||
@@ -137,28 +138,17 @@ function scoreText(candidate, targetText) {
|
|||||||
let hit = 0;
|
let hit = 0;
|
||||||
for (const w of tgtSet) if (blkSet.has(w)) hit++;
|
for (const w of tgtSet) if (blkSet.has(w)) hit++;
|
||||||
|
|
||||||
// Bonus si un long préfixe ressemble
|
|
||||||
const tgtNorm = normalizeText(stripMd(targetText));
|
const tgtNorm = normalizeText(stripMd(targetText));
|
||||||
const blkNorm = normalizeText(stripMd(candidate));
|
const blkNorm = normalizeText(stripMd(candidate));
|
||||||
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
|
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
|
||||||
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
|
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
|
||||||
|
|
||||||
// Ratio bonus (0..100)
|
|
||||||
const ratio = hit / Math.max(1, tgtSet.size);
|
const ratio = hit / Math.max(1, tgtSet.size);
|
||||||
const ratioBonus = Math.round(ratio * 100);
|
const ratioBonus = Math.round(ratio * 100);
|
||||||
|
|
||||||
return prefixBonus + hit + ratioBonus;
|
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) {
|
function rankedBlockMatches(blocks, targetText, limit = 5) {
|
||||||
return blocks
|
return blocks
|
||||||
.map((b, i) => ({
|
.map((b, i) => ({
|
||||||
@@ -170,11 +160,6 @@ function rankedBlockMatches(blocks, targetText, limit = 5) {
|
|||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitParagraphBlocks(mdxText) {
|
|
||||||
const raw = String(mdxText ?? "").replace(/\r\n/g, "\n");
|
|
||||||
return raw.split(/\n{2,}/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyExcerpt(s) {
|
function isLikelyExcerpt(s) {
|
||||||
const t = String(s || "").trim();
|
const t = String(s || "").trim();
|
||||||
if (!t) return true;
|
if (!t) return true;
|
||||||
@@ -184,6 +169,89 @@ function isLikelyExcerpt(s) {
|
|||||||
return false;
|
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 ------------------------------ */
|
/* ------------------------------ utils système ------------------------------ */
|
||||||
|
|
||||||
function run(cmd, args, opts = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
@@ -263,7 +331,9 @@ function pickSection(body, markers) {
|
|||||||
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||||
.filter((x) => x.i >= 0)
|
.filter((x) => x.i >= 0)
|
||||||
.sort((a, b) => a.i - b.i)[0];
|
.sort((a, b) => a.i - b.i)[0];
|
||||||
|
|
||||||
if (!idx) return "";
|
if (!idx) return "";
|
||||||
|
|
||||||
const start = idx.i + idx.m.length;
|
const start = idx.i + idx.m.length;
|
||||||
const tail = text.slice(start);
|
const tail = text.slice(start);
|
||||||
|
|
||||||
@@ -278,11 +348,13 @@ function pickSection(body, markers) {
|
|||||||
"\n## Proposition",
|
"\n## Proposition",
|
||||||
"\n## Problème",
|
"\n## Problème",
|
||||||
];
|
];
|
||||||
|
|
||||||
let end = tail.length;
|
let end = tail.length;
|
||||||
for (const s of stops) {
|
for (const s of stops) {
|
||||||
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
||||||
if (j >= 0 && j < end) end = j;
|
if (j >= 0 && j < end) end = j;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tail.slice(0, end).trim();
|
return tail.slice(0, end).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,8 +382,6 @@ function extractAnchorIdAnywhere(text) {
|
|||||||
|
|
||||||
function extractCheminFromAnyUrl(text) {
|
function extractCheminFromAnyUrl(text) {
|
||||||
const s = String(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);
|
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
|
||||||
return m ? m[1] : "";
|
return m ? m[1] : "";
|
||||||
}
|
}
|
||||||
@@ -412,7 +482,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${token}`,
|
Authorization: `token ${token}`,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent": "archicratie-apply-ticket/2.0",
|
"User-Agent": "archicratie-apply-ticket/2.1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -428,7 +498,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
|
|||||||
Authorization: `token ${token}`,
|
Authorization: `token ${token}`,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": "archicratie-apply-ticket/2.0",
|
"User-Agent": "archicratie-apply-ticket/2.1",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (comment) {
|
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 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) {
|
if (!res.ok) {
|
||||||
const t = await res.text().catch(() => "");
|
const t = await res.text().catch(() => "");
|
||||||
@@ -541,10 +615,9 @@ async function main() {
|
|||||||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||||||
|
|
||||||
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
|
|
||||||
if (issue?.pull_request) {
|
if (issue?.pull_request) {
|
||||||
console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`);
|
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);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +638,6 @@ async function main() {
|
|||||||
ancre = (ancre || "").trim();
|
ancre = (ancre || "").trim();
|
||||||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||||||
|
|
||||||
// fallback si ticket mal formé
|
|
||||||
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
|
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
|
||||||
|
|
||||||
chemin = normalizeChemin(chemin);
|
chemin = normalizeChemin(chemin);
|
||||||
@@ -604,7 +676,6 @@ async function main() {
|
|||||||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
|
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
|
||||||
await ensureBuildIfNeeded(distHtmlPath);
|
await ensureBuildIfNeeded(distHtmlPath);
|
||||||
|
|
||||||
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
|
|
||||||
let targetText = texteActuel;
|
let targetText = texteActuel;
|
||||||
let distText = "";
|
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).");
|
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 originalRaw = await fs.readFile(contentFile, "utf-8");
|
||||||
const blocks = splitParagraphBlocks(original);
|
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 ranked = rankedBlockMatches(blocks, targetText, 5);
|
||||||
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
|
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
|
||||||
const runnerUp = ranked[1] || null;
|
const runnerUp = ranked[1] || null;
|
||||||
|
|
||||||
// seuil absolu
|
|
||||||
if (best.i < 0 || best.score < 40) {
|
if (best.i < 0 || best.score < 40) {
|
||||||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
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(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
|
||||||
|
|
||||||
console.error("Top candidates:");
|
console.error("Top candidates:");
|
||||||
for (const r of ranked) {
|
for (const r of ranked) {
|
||||||
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
|
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
|
||||||
@@ -640,7 +717,6 @@ async function main() {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// seuil relatif : si le 2e est trop proche du 1er, on refuse aussi
|
|
||||||
if (runnerUp) {
|
if (runnerUp) {
|
||||||
const ambiguityGap = best.score - runnerUp.score;
|
const ambiguityGap = best.score - runnerUp.score;
|
||||||
if (ambiguityGap < 15) {
|
if (ambiguityGap < 15) {
|
||||||
@@ -659,7 +735,16 @@ async function main() {
|
|||||||
|
|
||||||
const nextBlocks = blocks.slice();
|
const nextBlocks = blocks.slice();
|
||||||
nextBlocks[best.i] = afterBlock;
|
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}`);
|
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
|
||||||
|
|
||||||
@@ -673,16 +758,15 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// backup uniquement si on écrit
|
|
||||||
const relContentFile = path.relative(CWD, contentFile);
|
const relContentFile = path.relative(CWD, contentFile);
|
||||||
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
|
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
|
||||||
await fs.mkdir(path.dirname(bakPath), { recursive: true });
|
await fs.mkdir(path.dirname(bakPath), { recursive: true });
|
||||||
|
|
||||||
if (!(await fileExists(bakPath))) {
|
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.");
|
console.log("✅ Applied.");
|
||||||
|
|
||||||
let aliasChanged = false;
|
let aliasChanged = false;
|
||||||
@@ -703,13 +787,11 @@ async function main() {
|
|||||||
|
|
||||||
if (aliasChanged) {
|
if (aliasChanged) {
|
||||||
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
|
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
|
||||||
// MàJ dist sans rebuild complet (inject seulement)
|
|
||||||
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
|
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
|
||||||
} else {
|
} else {
|
||||||
console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
|
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/check-anchor-aliases.mjs"], { cwd: CWD });
|
||||||
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
|
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
|
||||||
run("npm", ["run", "test:anchors"], { cwd: CWD });
|
run("npm", ["run", "test:anchors"], { cwd: CWD });
|
||||||
@@ -741,7 +823,6 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mode manuel
|
|
||||||
console.log("Next (manuel) :");
|
console.log("Next (manuel) :");
|
||||||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -758,4 +839,4 @@ async function main() {
|
|||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error("💥", e?.message || e);
|
console.error("💥", e?.message || e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user