Compare commits

...

5 Commits

Author SHA1 Message Date
30d5a20572 m2: apply-ticket supports --close (+ PR guard)
Some checks failed
CI / build-and-anchors (push) Failing after 33s
CI / build-and-anchors (pull_request) Failing after 34s
2026-01-20 21:54:36 +01:00
0abf98aa1f m2: apply-ticket fallback anchor/chemin parsing
Some checks failed
CI / build-and-anchors (push) Failing after 34s
2026-01-20 20:24:57 +01:00
d87d8c0a8f m2: apply-ticket supports --alias and --commit
Some checks failed
CI / build-and-anchors (push) Failing after 34s
CI / build-and-anchors (pull_request) Failing after 34s
2026-01-20 19:53:05 +01:00
5c00593e67 p0: restore anchor alias p-8-e7075fe3 -> p-8-0e65838d
Some checks failed
CI / build-and-anchors (push) Failing after 43s
2026-01-20 19:09:55 +01:00
b2b3d5621b p0: add anchor aliases for tickets #39 and #40
Some checks failed
CI / build-and-anchors (push) Failing after 30s
2026-01-20 19:00:56 +01:00
3 changed files with 436 additions and 99 deletions

View File

@@ -4,23 +4,44 @@ import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
/**
* apply-ticket — applique une proposition de correction depuis un ticket Gitea
*
* Conçu pour:
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
* - retrouver le bon paragraphe dans le .mdx
* - remplacer proprement
* - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit)
*/
function usage(exitCode = 0) {
console.log(`
apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste)
Usage:
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build]
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build] [--alias] [--commit] [--close]
Flags:
--dry-run : ne modifie rien, affiche BEFORE/AFTER
--no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias)
--alias : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json
--commit : git add + git commit automatiquement (inclut alias si --alias)
--close : ferme automatiquement le ticket après commit (+ commentaire avec SHA)
Env (recommandé):
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 (évite DNS)
FORGE_BASE = base web ex: https://gitea.xxx.tld
FORGE_TOKEN = PAT (avec accès au repo + issues)
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000
FORGE_BASE = base web ex: https://gitea.xxx.tld (fallback si FORGE_API absent)
FORGE_TOKEN = PAT (accès repo + issues)
GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote)
GITEA_REPO = repo (optionnel si auto-détecté depuis git remote)
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)
- 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.
`);
process.exit(exitCode);
}
@@ -36,10 +57,40 @@ if (!Number.isFinite(issueNum) || issueNum <= 0) {
const DRY_RUN = argv.includes("--dry-run");
const NO_BUILD = argv.includes("--no-build");
const DO_ALIAS = argv.includes("--alias");
const DO_COMMIT = argv.includes("--commit");
const DO_CLOSE = argv.includes("--close");
if (DO_ALIAS && NO_BUILD) {
console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux).");
console.error("➡️ Relance sans --no-build.");
process.exit(1);
}
if (DRY_RUN && (DO_ALIAS || DO_COMMIT || DO_CLOSE)) {
console.warn(" --dry-run : --alias/--commit/--close sont ignorés (aucune écriture).");
}
if (DO_CLOSE && DRY_RUN) {
console.error("❌ --close est incompatible avec --dry-run.");
process.exit(1);
}
if (DO_CLOSE && !DO_COMMIT) {
console.error("❌ --close nécessite --commit (on ne ferme jamais un ticket sans commit).");
process.exit(1);
}
if (typeof fetch !== "function") {
console.error("❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus).");
process.exit(1);
}
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");
/* -------------------------- utils texte / matching -------------------------- */
function normalizeText(s) {
return String(s ?? "")
@@ -57,11 +108,11 @@ function normalizeText(s) {
// stripping très pragmatique
function stripMd(mdx) {
let s = String(mdx ?? "");
s = s.replace(/`[^`]*`/g, " "); // inline code
s = s.replace(/`[^`]*`/g, " "); // inline code
s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
s = s.replace(/[*_~]/g, " "); // emphasis-ish
s = s.replace(/<[^>]+>/g, " "); // html tags
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
s = s.replace(/[*_~]/g, " "); // emphasis-ish
s = s.replace(/<[^>]+>/g, " "); // html tags
s = s.replace(/\s+/g, " ").trim();
return s;
}
@@ -74,13 +125,78 @@ function tokenize(s) {
.filter((w) => w.length >= 4);
}
function scoreText(candidate, targetText) {
const tgt = tokenize(targetText);
const blk = tokenize(candidate);
if (!tgt.length || !blk.length) return 0;
const tgtSet = new Set(tgt);
const blkSet = new Set(blk);
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 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;
if (t.length < 120) return true;
if (/[.…]$/.test(t)) return true;
if (normalizeText(t).includes("tronqu")) return true;
return false;
}
/* ------------------------------ utils système ------------------------------ */
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
if (r.error) throw r.error;
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}
function runQuiet(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts });
if (r.error) throw r.error;
if (r.status !== 0) {
const out = (r.stdout || "") + (r.stderr || "");
throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`);
}
return r.stdout || "";
}
async function fileExists(p) {
try { await fs.access(p); return true; } catch { return false; }
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
function getEnv(name, fallback = "") {
@@ -96,21 +212,31 @@ function inferOwnerRepoFromGit() {
return { owner: m.groups.owner, repo: m.groups.repo };
}
function gitHasStagedChanges() {
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
return r.status === 1;
}
/* ------------------------------ parsing ticket ----------------------------- */
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function pickLine(body, key) {
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = body.match(re);
const m = String(body || "").match(re);
return m ? m[1].trim() : "";
}
function pickHeadingValue(body, headingKey) {
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
const m = body.match(re);
const re = new RegExp(
`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
"mi"
);
const m = String(body || "").match(re);
if (!m) return "";
const lines = m[1].split(/\r?\n/).map(l => l.trim());
const lines = m[1].split(/\r?\n/).map((l) => l.trim());
for (const l of lines) {
if (!l) continue;
if (l.startsWith("<!--")) continue;
@@ -120,18 +246,25 @@ function pickHeadingValue(body, headingKey) {
}
function pickSection(body, markers) {
const text = body.replace(/\r\n/g, "\n");
const text = String(body || "").replace(/\r\n/g, "\n");
const idx = markers
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter(x => x.i >= 0)
.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);
const stops = [
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
"\n## ",
"\nJustification",
"\n---",
"\n## Justification",
"\n## Sources",
"\nProblème identifié",
"\nSources proposées",
"\n## Proposition",
"\n## Problème",
];
let end = tail.length;
for (const s of stops) {
@@ -144,83 +277,84 @@ function pickSection(body, markers) {
function unquoteBlock(s) {
return String(s ?? "")
.split(/\r?\n/)
.map(l => l.replace(/^\s*>\s?/, ""))
.map((l) => l.replace(/^\s*>\s?/, ""))
.join("\n")
.trim();
}
function normalizeChemin(chemin) {
let c = String(chemin || "").trim();
if (!c) return "";
if (!c.startsWith("/")) c = "/" + c;
if (!c.endsWith("/")) c = c + "/";
return c;
}
function extractAnchorIdAnywhere(text) {
const s = String(text || "");
const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
return m ? m[1] : "";
}
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] : "";
}
/* --------------------------- lecture HTML paragraphe ------------------------ */
function cleanHtmlInner(inner) {
let s = String(inner ?? "");
s = s.replace(
/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi,
" "
);
s = s.replace(/<[^>]+>/g, " ");
s = s.replace(/\s+/g, " ").trim();
s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
return s;
}
async function readHtmlParagraphText(htmlPath, anchorId) {
const html = await fs.readFile(htmlPath, "utf-8");
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
const re = new RegExp(
`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`,
"i"
);
const m = html.match(re);
if (!m) return "";
let inner = m[1];
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
inner = inner.replace(/<[^>]+>/g, " ");
inner = inner.replace(/\s+/g, " ").trim();
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
return inner;
return cleanHtmlInner(m[1]);
}
function splitParagraphBlocks(mdxText) {
const raw = mdxText.replace(/\r\n/g, "\n");
return raw.split(/\n{2,}/);
}
function isLikelyExcerpt(s) {
const t = String(s || "").trim();
if (!t) return true;
if (t.length < 120) return true;
if (/[.…]$/.test(t)) return true;
if (t.includes("tronqu")) return true; // tronqué/tronquee etc (sans diacritiques)
return false;
}
function scoreBlock(block, targetText) {
const tgt = tokenize(targetText);
const blk = tokenize(block);
if (!tgt.length || !blk.length) return 0;
const tgtSet = new Set(tgt);
const blkSet = new Set(blk);
let hit = 0;
for (const w of tgtSet) if (blkSet.has(w)) hit++;
// Bonus si un long préfixe ressemble (moins strict qu'un includes brut)
const tgtNorm = normalizeText(stripMd(targetText));
const blkNorm = normalizeText(stripMd(block));
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 b = blocks[i];
const sc = scoreBlock(b, targetText);
if (sc > best.score) best = { i, score: sc };
async function readAllHtmlParagraphs(htmlPath) {
const html = await fs.readFile(htmlPath, "utf-8");
const out = [];
const re = /<p\b[^>]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi;
let m;
while ((m = re.exec(html))) {
out.push({ id: m[1], text: cleanHtmlInner(m[2]) });
}
return best;
return out;
}
/* --------------------------- localisation fichier contenu ------------------- */
async function findContentFileFromChemin(chemin) {
const clean = chemin.replace(/^\/+|\/+$/g, "");
const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
const parts = clean.split("/").filter(Boolean);
if (parts.length < 2) return null;
const collection = parts[0];
const slugPath = parts.slice(1).join("/");
const root = path.join(CONTENT_ROOT, collection);
if (!(await fileExists(root))) return null;
const exts = [".mdx", ".md"];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
@@ -240,36 +374,137 @@ async function findContentFileFromChemin(chemin) {
}
return null;
}
return await walk(root);
}
/* -------------------------------- build helper ----------------------------- */
async function ensureBuildIfNeeded(distHtmlPath) {
if (NO_BUILD) return;
if (await fileExists(distHtmlPath)) return;
console.log(" dist manquant pour cette page → build (npm run build) …");
run("npm", ["run", "build"], { cwd: CWD });
if (!(await fileExists(distHtmlPath))) {
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
}
}
/* ----------------------------- API Gitea helpers --------------------------- */
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const url = `${forgeApiBase.replace(/\/+$/, "")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, {
headers: {
"Authorization": `token ${token}`,
"Accept": "application/json",
"User-Agent": "archicratie-apply-ticket/1.1",
}
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
},
});
if (!res.ok) {
const t = await res.text().catch(()=> "");
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
}
return await res.json();
}
async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
const base = forgeApiBase.replace(/\/+$/, "");
const headers = {
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
};
if (comment) {
const urlC = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
await fetch(urlC, { method: "POST", headers, body: JSON.stringify({ body: comment }) });
}
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} closing issue: ${url}\n${t}`);
}
}
/* ------------------------------ Aliases helpers ---------------------------- */
async function loadAliases() {
try {
const s = await fs.readFile(ALIASES_FILE, "utf8");
const obj = JSON.parse(s);
return obj && typeof obj === "object" ? obj : {};
} catch {
return {};
}
}
function sortObjectKeys(obj) {
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
}
async function saveAliases(obj) {
let out = obj || {};
for (const k of Object.keys(out)) {
if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
}
out = sortObjectKeys(out);
await fs.mkdir(path.dirname(ALIASES_FILE), { recursive: true });
await fs.writeFile(ALIASES_FILE, JSON.stringify(out, null, 2) + "\n", "utf8");
}
async function upsertAlias({ chemin, oldId, newId }) {
const route = normalizeChemin(chemin);
if (!oldId || !newId) throw new Error("Alias: oldId/newId requis");
if (oldId === newId) return { changed: false, reason: "same" };
const data = await loadAliases();
if (!data[route]) data[route] = {};
const prev = data[route][oldId];
if (prev && prev !== newId) {
throw new Error(
`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`
);
}
if (prev === newId) return { changed: false, reason: "already" };
data[route][oldId] = newId;
await saveAliases(data);
return { changed: true, reason: "written" };
}
async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
const paras = await readAllHtmlParagraphs(distHtmlPath);
if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`);
let best = { id: null, score: -1 };
const target = stripMd(afterBlock).slice(0, 1200);
for (const p of paras) {
const sc = scoreText(p.text, target);
if (sc > best.score) best = { id: p.id, score: sc };
}
if (!best.id || best.score < 60) {
throw new Error(
`Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
`➡️ Vérifie que la proposition correspond bien à UN paragraphe.`
);
}
return best.id;
}
/* ----------------------------------- MAIN ---------------------------------- */
async function main() {
const token = getEnv("FORGE_TOKEN");
if (!token) {
@@ -279,7 +514,7 @@ async function main() {
const inferred = inferOwnerRepoFromGit() || {};
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
const repo = getEnv("GITEA_REPO", inferred.repo || "");
const repo = getEnv("GITEA_REPO", inferred.repo || "");
if (!owner || !repo) {
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
process.exit(1);
@@ -294,19 +529,54 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
const body = String(issue.body || "").replace(/\r\n/g, "\n");
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`);
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
process.exit(2);
}
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const title = String(issue.title || "");
let chemin =
pickLine(body, "Chemin") ||
pickHeadingValue(body, "Chemin") ||
extractCheminFromAnyUrl(body) ||
extractCheminFromAnyUrl(title);
let ancre =
pickLine(body, "Ancre") ||
pickHeadingValue(body, "Ancre paragraphe") ||
pickHeadingValue(body, "Ancre");
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1);
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
// fallback si ticket mal formé
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin);
const currentFull = pickSection(body, [
"Texte actuel (copie exacte du paragraphe)",
"## Texte actuel (copie exacte du paragraphe)",
]);
const currentEx = pickSection(body, [
"Texte actuel (extrait)",
"## Assertion / passage à vérifier",
"Assertion / passage à vérifier",
]);
const texteActuel = unquoteBlock(currentFull || currentEx);
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
const prop1 = pickSection(body, [
"Proposition (texte corrigé complet)",
"## Proposition (texte corrigé complet)",
]);
const prop2 = pickSection(body, [
"Proposition (remplacer par):",
"## Proposition (remplacer par)",
]);
const proposition = (prop1 || prop2).trim();
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
@@ -319,13 +589,13 @@ async function main() {
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
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);
// targetText: préférence au texte complet (ticket), sinon dist si extrait probable
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel;
let distText = "";
if (await fileExists(distHtmlPath)) {
distText = await readHtmlParagraphText(distHtmlPath, ancre);
}
@@ -344,14 +614,13 @@ async function main() {
const best = bestBlockMatchIndex(blocks, targetText);
// seuil de sécurité : on veut au moins un overlap raisonnable.
// Avec le bonus prefix+ratio, un match correct dépasse très vite ~6080.
// seuil de sécurité
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)'.`);
// debug: top 5
const ranked = blocks
.map((b, i) => ({ i, score: scoreBlock(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
@@ -388,10 +657,74 @@ async function main() {
}
await fs.writeFile(contentFile, updated, "utf-8");
console.log("✅ Applied. Next:");
console.log("✅ Applied.");
let aliasChanged = false;
let newId = null;
if (DO_ALIAS) {
console.log("🔁 Rebuild to compute new anchor ids (npm run build) …");
run("npm", ["run", "build"], { cwd: CWD });
if (!(await fileExists(distHtmlPath))) {
throw new Error(`dist introuvable après build: ${distHtmlPath}`);
}
newId = await computeNewIdFromDistByContent(distHtmlPath, afterBlock);
const res = await upsertAlias({ chemin, oldId: ancre, newId });
aliasChanged = res.changed;
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("npm", ["run", "test:anchors"], { cwd: CWD });
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
}
if (DO_COMMIT) {
const files = [path.relative(CWD, contentFile)];
if (DO_ALIAS && aliasChanged) files.push(path.relative(CWD, ALIASES_FILE));
run("git", ["add", ...files], { cwd: CWD });
if (!gitHasStagedChanges()) {
console.log(" Nothing to commit (aucun changement staged).");
return;
}
const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
run("git", ["commit", "-m", msg], { cwd: CWD });
const sha = runQuiet("git", ["rev-parse", "--short", "HEAD"], { cwd: CWD }).trim();
console.log(`✅ Committed: ${msg} (${sha})`);
if (DO_CLOSE) {
const comment = `✅ Appliqué par apply-ticket.\nCommit: ${sha}`;
await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
console.log(`✅ Ticket #${issueNum} fermé.`);
}
return;
}
// mode manuel
console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log(` git add ${path.relative(CWD, contentFile)}`);
console.log(
` git add ${path.relative(CWD, contentFile)}${
DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""
}`
);
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
if (DO_CLOSE) {
console.log(" (puis relance avec --commit --close pour fermer automatiquement)");
}
}
main().catch((e) => {

View File

@@ -1,6 +1,8 @@
{
"/archicratie/prologue/": {
"p-8-e7075fe3": "p-8-0e65838d"
"p-8-e7075fe3": "p-8-0e65838d",
"p-3-76df8102": "p-3-539ac0fd",
"p-5-85126fa5": "p-5-285d27a7"
}
}

View File

@@ -7,9 +7,9 @@
"p-0-d7974f88",
"p-1-2ef25f29",
"p-2-edb49e0a",
"p-3-76df8102",
"p-3-539ac0fd",
"p-4-8ed4f807",
"p-5-85126fa5",
"p-5-285d27a7",
"p-6-3515039d",
"p-7-64a0ca9c",
"p-8-0e65838d",
@@ -142,6 +142,8 @@
"p-135-c19330ce",
"p-136-17f1cf51",
"p-137-d8f1539e",
"p-3-76df8102",
"p-5-85126fa5",
"p-8-e7075fe3"
],
"atlas/00-demarrage/index.html": [