expria-frontend/scripts/sync-roadmap.mjs
Hermann_Kitio eb8987ddb3
Some checks are pending
CI / quality (push) Waiting to run
chore(roadmap): script de synchro frontend -> backend (sens unique, garde durcie)
- scripts/sync-roadmap.mjs : copie ROADMAP frontend (source de verite) vers backend (copie generee).
- Garde sens-unique : identite de chemins resolus + refus si cible dans le repo source + casse win32.
- Validation source (existe, non vide, header attendu) ; diff + confirmation y/N ; flag --check (dry-run).
- Banniere auto-generee en tete de la copie backend (NE PAS EDITER A LA MAIN), ignoree au diff (idempotent).
- Pas de commit auto : rappelle la marche a suivre manuelle.
- alias npm sync:roadmap.
2026-06-29 23:10:43 +03:00

195 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// @ts-check
/**
* sync-roadmap.mjs — synchronise la ROADMAP frontend → backend (sens UNIQUE).
*
* SOURCE DE VÉRITÉ : expria-frontend/docs/ROADMAP.md (versionnée).
* CIBLE (copie générée) : expria-backend/docs/ROADMAP.md.
*
* HYPOTHÈSE D'ARBORESCENCE : les deux repos sont FRÈRES sous un parent commun,
* p.ex. D:\expria-v2\{expria-frontend, expria-backend}. Ce script ne fonctionne
* QUE dans cette disposition. Override possible via la variable d'environnement
* EXPRIA_BACKEND_DIR (chemin absolu vers le repo expria-backend).
*
* SENS UNIQUE STRICT : jamais backend → frontend. Aucun paramètre n'inverse le sens ;
* une vérification défensive refuse d'écrire vers un chemin contenant 'expria-frontend'.
*
* DÉCISION (B) — bannière auto-générée : la cible reçoit, EN TÊTE, un commentaire HTML
* (invisible au rendu Markdown) avertissant que le fichier est généré. Le diff IGNORE
* cette bannière (comparaison corps source vs corps cible), pour rester idempotent.
*
* PAS DE COMMIT AUTO : le script écrit le fichier uniquement ; le commit backend reste
* une action manuelle validée.
*
* USAGE (depuis expria-frontend/) :
* npm run sync:roadmap # interactif : montre le diff, demande confirmation y/N
* node scripts/sync-roadmap.mjs --check # dry-run : exit 1 si désynchro, n'écrit rien
* node scripts/sync-roadmap.mjs --yes # non-interactif : écrit sans confirmation
*/
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve, sep } from 'node:path'
import { createInterface } from 'node:readline'
const BANNER =
'<!-- AUTO-GÉNÉRÉ depuis expria-frontend/docs/ROADMAP.md — NE PAS ÉDITER À LA MAIN.\n' +
' Toute modification passe par le frontend, puis : npm run sync:roadmap -->\n\n'
const EXPECTED_HEADER = '# ROADMAP.md'
/** Affiche un message d'erreur et termine en échec. */
function fail(message) {
console.error(`\n[sync-roadmap] ERREUR : ${message}\n`)
process.exit(1)
}
// --- Résolution des chemins (dérivés de la position du script) -------------
const scriptDir = dirname(fileURLToPath(import.meta.url))
const frontendRoot = resolve(scriptDir, '..')
const sourcePath = join(frontendRoot, 'docs', 'ROADMAP.md')
const backendRoot = process.env.EXPRIA_BACKEND_DIR
? resolve(process.env.EXPRIA_BACKEND_DIR)
: resolve(frontendRoot, '..', 'expria-backend')
const backendDocsDir = join(backendRoot, 'docs')
const targetPath = join(backendDocsDir, 'ROADMAP.md')
// --- Garde-fou : sens unique strict ----------------------------------------
// La cible ne doit JAMAIS être (ni vivre dans) le repo source. La garde qui fait
// foi est l'identité de chemins RÉSOLUS ; le test de sous-chaîne ne reste qu'un
// signal secondaire. Sur Windows les chemins sont insensibles à la casse.
const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
const resolvedTarget = norm(resolve(targetPath))
const resolvedSource = norm(resolve(sourcePath))
const resolvedFrontend = norm(resolve(frontendRoot))
// 1. Garde PRIMAIRE : cible == source (identité de chemin résolu).
if (resolvedTarget === resolvedSource) {
fail(`refus : la cible EST la source (sens unique). Chemin = ${targetPath}`)
}
// 2. Garde ÉLARGIE : cible à l'intérieur du repo source (tout fichier du frontend).
if (resolvedTarget.startsWith(resolvedFrontend + sep)) {
fail(`refus : la cible est à l'intérieur du repo source (sens unique). Cible = ${targetPath}`)
}
// 3. Garde SECONDAIRE (signal de bon sens) : nom contenant 'expria-frontend'.
if (resolvedTarget.includes('expria-frontend')) {
fail(`refus : la cible référence le repo source (sens unique). Cible = ${targetPath}`)
}
// --- Garde-fou : arborescence backend --------------------------------------
if (!existsSync(backendDocsDir)) {
fail(
`repo backend introuvable comme frère.\n` +
` Attendu : ${backendDocsDir}\n` +
` Astuce : place expria-backend à côté de expria-frontend, ` +
`ou exporte EXPRIA_BACKEND_DIR vers le repo backend.`,
)
}
// --- Garde-fou : source valide ---------------------------------------------
if (!existsSync(sourcePath)) {
fail(`source introuvable : ${sourcePath}`)
}
if (statSync(sourcePath).size === 0) {
fail(`source vide : ${sourcePath} (on ne remplace jamais la cible par du vide)`)
}
const sourceBody = readFileSync(sourcePath, 'utf8')
if (!sourceBody.trimStart().startsWith(EXPECTED_HEADER)) {
fail(
`la source ne commence pas par « ${EXPECTED_HEADER} » — ` +
`format inattendu, abandon par sécurité.`,
)
}
// --- Calcul du contenu cible (bannière + corps source) ---------------------
const desiredTarget = BANNER + sourceBody
/** Retire la bannière auto-générée d'un contenu cible, pour comparer le corps seul. */
function stripBanner(content) {
if (content.startsWith('<!-- AUTO-GÉNÉRÉ')) {
const end = content.indexOf('-->')
if (end !== -1) {
// Saute le '-->' puis les sauts de ligne qui le suivent.
return content.slice(end + 3).replace(/^\r?\n+/, '')
}
}
return content
}
const currentTargetRaw = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
const currentBody = currentTargetRaw === null ? null : stripBanner(currentTargetRaw)
// --- Idempotence : déjà synchro ? ------------------------------------------
// On compare les CORPS (bannière ignorée).
if (currentBody === sourceBody) {
console.log('[sync-roadmap] déjà synchro — rien à faire.')
process.exit(0)
}
// --- Diff (corps source vs corps cible, bannière exclue) -------------------
function printDiff(oldBody, newBody) {
const oldLines = (oldBody ?? '').split('\n')
const newLines = newBody.split('\n')
const max = Math.max(oldLines.length, newLines.length)
console.log('\n[sync-roadmap] différences (corps, bannière exclue) :')
console.log(` source : ${sourcePath}`)
console.log(` cible : ${targetPath}\n`)
let shown = 0
for (let i = 0; i < max; i++) {
if (oldLines[i] !== newLines[i]) {
if (oldLines[i] !== undefined) console.log(` - ${oldLines[i]}`)
if (newLines[i] !== undefined) console.log(` + ${newLines[i]}`)
shown++
}
}
if (currentTargetRaw === null) {
console.log(" (la cible n'existe pas encore — création complète)")
}
console.log(`\n ${shown} ligne(s) divergente(s).`)
}
printDiff(currentBody, sourceBody)
const args = new Set(process.argv.slice(2))
// --- Mode --check : dry-run, signale la désynchro, n'écrit rien ------------
if (args.has('--check')) {
console.error('\n[sync-roadmap] --check : cible DÉSYNCHRONISÉE (aucune écriture).\n')
process.exit(1)
}
/** Demande une confirmation y/N (refus par défaut). */
function confirm(question) {
return new Promise((res) => {
const rl = createInterface({ input: process.stdin, output: process.stdout })
rl.question(question, (answer) => {
rl.close()
res(/^y(es)?$/i.test(answer.trim()))
})
})
}
/** Écrit la cible et rappelle la marche à suivre manuelle. */
function writeTarget() {
writeFileSync(targetPath, desiredTarget, 'utf8')
console.log(`\n[sync-roadmap] écrit : ${targetPath}`)
console.log('\n[sync-roadmap] PAS de commit automatique. Marche à suivre manuelle :')
console.log(' cd ../expria-backend')
console.log(' git add docs/ROADMAP.md')
console.log(' git commit -m "docs(roadmap): sync depuis frontend"\n')
}
if (args.has('--yes')) {
writeTarget()
process.exit(0)
}
const ok = await confirm('\n[sync-roadmap] Écrire la cible backend ? (y/N) ')
if (!ok) {
console.log('[sync-roadmap] annulé — aucune écriture.')
process.exit(0)
}
writeTarget()