@@ -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 ) ;
} ) ;
} ) ;