#!/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 = '\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('') 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()