Some checks are pending
CI / quality (push) Waiting to run
- 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.
195 lines
7.6 KiB
JavaScript
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()
|