Compare commits

...

7 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
4c7b6a772c edit: apply ticket #40 (/archicratie/prologue/#p-5-85126fa5)
Some checks failed
CI / build-and-anchors (push) Failing after 27s
2026-01-20 18:57:12 +01:00
11e45eb9d0 edit: apply ticket #39 (/archicratie/prologue/#p-3-76df8102) 2026-01-20 18:57:03 +01:00
4 changed files with 438 additions and 102 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

@@ -17,12 +17,11 @@ C'est cette perte de prise sur le réel que ce livre souhaite prendre au sérieu
Cette tenue du monde n'équivaut ni à la paix civile, ni à la stabilité des institutions, ni à l'ordre établi. C'est une difficulté conceptuelle que d'envisager *la possibilité pour un ordre de durer sans s'effondrer*, alors même qu'il est traversé en permanence par des forces et des légitimités qui le travaillent, l'éprouvent, le modifient, l'usent, le contestent, le prolongent ou le sapent. Cette possibilité de tenir le monde commun, nous la nommons *co-viabilité*.
Le terme n'est pas trivial. Il ne s'agit pas simplement d'une viabilité partagée, ni d'une coexistence pacifique, ni même d'une durabilité écologique élargie. Il s'agit d'un état dynamique, instable, fragile, dans lequel un ensemble — une société, d'un système biologique, d'une formation historique, d'un milieu technique ou d'un monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*.
Le terme nest pas trivial. Il ne sagit pas simplement dune viabilité partagée, ni dune coexistence pacifique, ni même dune durabilité écologique élargie. Il sagit dun état dynamique, instable, fragile, dans lequel un ensemble — une société, un système biologique, une formation historique, un milieu technique ou un monde institué — parvient à maintenir une existence viable, malgré et grâce à ses tensions constitutives.
La *co-viabilité* ne désigne ni un état d'équilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce qu'il parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces d'inertie et d'innovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. C'est cette disposition active, faite de compromis fragiles et d'ajustements toujours révisables, que nous tenons pour première, et non dérivée.
Ce qui revient à dire que la question politique — au sens fort — n'a peut-être jamais été qui commande ? Mais bien plus : *Comment un ordre tient-il malgré ce qui le défait ?* *Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous l'effet de ses propres contradictions ?* *Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ?*\
Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (*Économie et société*, 1922) rappelait que ce qui fait tenir un ordre, ce n'est pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (*La dynamique de l'Occident*, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche s'inscrit dans ce sillage : travailler cette interrogation sur les *conditions de viabilité d'un monde commun*.
Ce qui revient à dire que la question politique — au sens fort — na peut-être jamais été qui commande ? Mais bien plus : Comment un ordre tient-il malgré ce qui le défait ? Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous leffet de ses propres contradictions ? Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ? Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (Économie et société, 1922) rappelait que ce qui fait tenir un ordre, ce nest pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (La dynamique de lOccident, 1939/1974) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche sinscrit dans ce sillage : travailler cette interrogation sur les conditions de viabilité dun monde commun.
Ce changement de perspective implique une rupture profonde dans la manière même de poser la question politique. Pendant des siècles, les sociétés ont pensé le politique à partir de principes transcendants — Dieu, Nature, Volonté générale, Pacte social. Ces principes, supposés extérieurs aux conflits du présent, garantissaient l'ordre en surplomb. Comme le rappelle Michel Foucault, il n'y a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. C'est précisément cette exigence — trouver dans les relations elles-mêmes les ressources nécessaires pour maintenir des mondes vivables — qui définit notre époque.

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": [