chore(roadmap): script de synchro frontend -> backend (sens unique, garde durcie)
Some checks are pending
CI / quality (push) Waiting to run
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.
This commit is contained in:
parent
713d830be0
commit
eb8987ddb3
2 changed files with 197 additions and 1 deletions
|
|
@ -12,7 +12,8 @@
|
|||
"test:watch": "vitest",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"sync:roadmap": "node scripts/sync-roadmap.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.103.2",
|
||||
|
|
|
|||
195
scripts/sync-roadmap.mjs
Normal file
195
scripts/sync-roadmap.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue