/** * cli:menu-node — rename.ts * * Rename ONE node (module / section / resource) knowing `from → to` — no * heuristic. In order: the long codes of every `.md` of the tree (the module, * section or resource SEGMENT — 4-segment resource codes included), the human * permission paths of the module's rbac.md (machine blocks untouched), for a * module the `depends=` anchors + `## Dépendances` links of every module and * the `scope cross-module (APP/MOD)` relations of every entité.md, the * pagespec references (exact bounded tokens, own module + cross references), * then the folder move, the node's own anchor (`code=` + `previousCodes=` in * nav form — THE channel the prod seed delta reads) and the parent's * `## Enfants` line. Whatever the token lists do not cover is SAID by the * residual scan; what belongs to another writer is REPORTED, never written. * * Applications are refused (sources `scopes`, platform ApplicationId, the APP * segment of every code — a manual route with /ba-reconcile-menu). */ import { existsSync, renameSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { countPermissionPaths, countScopeCodes, findNode, foldMenuCode, navFormOf, renameCodeSegment, renameCrossModuleScopes, renamePagespecRefs, renamePermissionPaths, residualTokenScan, sectionFolderToCode, spliceDependances, spliceEnfants, updateNodeAnchor, type CodeLevel, type CodeScope, type MenuTree, } from '../../../../lib/ba-menu-tree.js' import { CLI_PATHS, Staging, accentedPath, checkNodeShape, hasMachineBlocks, isDir, nodeWarnings, pagespecFiles, rel, siblingsOf, walkMd } from './common.js' import { emptyPlan, type Blocked, type FileCount, type MenuNodeInput, type MenuNodeReport, type ResolvedScope } from './types.js' export function runRename(spec: MenuNodeInput, scope: ResolvedScope, tree: MenuTree): MenuNodeReport { const { baRoot, parentPath, parentDir } = scope const code = spec.code const to = spec.to! const nodeDir = join(parentDir, code) const newDir = join(parentDir, to.code) const newPath = [...parentPath, to.code].join('/') const oldPath = [...parentPath, code].join('/') const plan = emptyPlan() const blocked: Blocked[] = [] const warnings: string[] = [] const nextSteps: string[] = [] const report = (extra: Partial = {}): MenuNodeReport => ({ op: 'rename', mode: spec.mode, level: spec.level, node: { code: to.code, label: to.label, path: newPath, dir: newDir }, blocked, warnings, plan, applied: null, noop: false, nextSteps, ...extra, }) if (spec.level === 'application') { blocked.push({ code: 'app-rename-unsupported', reason: 'Renommer une APPLICATION touche le segment APP de tous les codes, les `scopes` du registre des sources et la correspondance avec les apps plateforme — hors périmètre de ce CLI.', route: 'Renommer le dossier à la main, puis /ba-reconcile-menu pour les documents aval, /ba-create-sources pour re-pointer les scopes, et relancer les audits.', }) return report() } const siblings = siblingsOf(tree, parentPath) const node = findNode(tree, [...parentPath, code]) const target = siblings.find((s) => foldMenuCode(s.code) === foldMenuCode(to.code)) // --- idempotence: already renamed ----------------------------------------------------- if (!node && target && target.anchor?.previousCodes.some((p) => navFormOf(p) === navFormOf(code))) { warnings.push(`${oldPath} n'existe plus et ${newPath} porte previousCodes=${navFormOf(code)} — renommage déjà appliqué.`) return report({ noop: true }) } if (!node) { blocked.push({ code: 'node-not-found', reason: `Le nœud ${oldPath} n'existe pas dans l'arbre.`, route: 'Vérifier le code (le dossier EST le code).', candidates: siblings.map((s) => s.code) }) return report() } if (node.code === to.code) { warnings.push(`${oldPath} porte déjà le code demandé — rien à faire.`) return report({ noop: true }) } if (target) { blocked.push({ code: 'target-exists', reason: `Un frère « ${target.code} » porte déjà le code cible.`, route: 'Fusionner les deux nœuds (SKILL.md § Fusion) ou choisir un autre code.', candidates: siblings.map((s) => s.code) }) } blocked.push(...checkNodeShape({ level: spec.level, code: to.code, label: to.label ?? node.label, parentPath })) warnings.push(...nodeWarnings(tree, { level: spec.level, code: to.code, label: to.label ?? node.label, parentPath })) if (blocked.length > 0) return report() // --- code scope ----------------------------------------------------------------------- const app = parentPath[0]! const mod = spec.level === 'module' ? code : parentPath[1]! const codeScope: CodeScope = { app, mod } const codeLevel: CodeLevel = spec.level as CodeLevel if (spec.level === 'section') codeScope.sec = sectionFolderToCode(code) if (spec.level === 'resource') { codeScope.sec = sectionFolderToCode(parentPath[2]!) codeScope.res = sectionFolderToCode(code) } const toLong = spec.level === 'module' ? to.code : sectionFolderToCode(to.code) const moduleDir = spec.level === 'module' ? nodeDir : join(baRoot, app, mod) const staging = new Staging() const count = (list: FileCount[], file: string, n: number): void => { if (n > 0) list.push({ file: rel(baRoot, file), count: n }) } // 1. long codes — every .md of the tree except _audit/ for (const file of walkMd(baRoot)) { const before = staging.read(file) if (before === null) continue const n = countScopeCodes(before, codeScope) if (n === 0) continue if (staging.set(file, renameCodeSegment(before, codeScope, codeLevel, toLong))) count(plan.codeRewrites, file, n) } // 2. rbac.md human permission paths of the module const rbacPath = join(moduleDir, 'rbac.md') const rbacBefore = staging.read(rbacPath) if (rbacBefore !== null) { const permScope = spec.level === 'module' ? { module: code } : spec.level === 'section' ? { module: mod, section: code } : { module: mod, section: parentPath[2]!, resource: code } const n = countPermissionPaths(rbacBefore, permScope) if (n > 0 && staging.set(rbacPath, renamePermissionPaths(rbacBefore, permScope, to.code))) count(plan.permissionPathRewrites, rbacPath, n) if (hasMachineBlocks(rbacBefore)) { plan.reportOnly.push({ what: 'Blocs machine `ba:rbac-floor` / `ba:rbac-derived-lookups` de rbac.md — chemins de nœuds périmés', where: rel(baRoot, rbacPath), route: `relancer derive-permission-floor et derive-lookup-grants en mode derive sur ${app}/${spec.level === 'module' ? to.code : mod}`, }) nextSteps.push( `npx --prefer-offline tsx ${CLI_PATHS.permissionFloor} --spec '{"baRoot":"${spec.baRoot}","app":"${app}","module":"${spec.level === 'module' ? to.code : mod}","mode":"derive"}' puis npx --prefer-offline tsx ${CLI_PATHS.lookupGrants} --spec '{"baRoot":"${spec.baRoot}","app":"${app}","module":"${spec.level === 'module' ? to.code : mod}","mode":"derive"}' (RBAC-008/009 signalent la dérive sinon).`, ) } } // 3. module rename — depends= anchors + ## Dépendances links + cross-module relation scopes if (spec.level === 'module') { const key = `${app}/${code}` for (const m of tree.all.filter((n) => n.level === 'module' && n.dir !== nodeDir)) { const indexPath = join(m.dir, 'index.md') const before = staging.read(indexPath) if (before === null) continue let after = before let n = 0 const anchorDepends = m.anchor?.depends ?? [] if (anchorDepends.some((d) => d.toLowerCase() === key.toLowerCase())) { after = updateNodeAnchor(after, { depends: anchorDepends.map((d) => (d.toLowerCase() === key.toLowerCase() ? `${app}/${to.code}` : d)) }) n += 1 } if (m.path[0] === app) { const dep = spliceDependances(after, { op: 'rename', code, newCode: to.code }) if (dep.changed) { after = dep.content n += 1 } } if (n > 0 && staging.set(indexPath, after)) count(plan.dependsRewrites, indexPath, n) const entitePath = accentedPath(m.dir, 'entité.md') if (entitePath) { const eBefore = staging.read(entitePath)! const r = renameCrossModuleScopes(eBefore, app, code, to.code) if (r.count > 0 && staging.set(entitePath, r.content)) count(plan.crossModuleScopeRewrites, entitePath, r.count) } } } // 4. pagespecs — own module identity + cross references everywhere if (spec.level === 'module' || spec.level === 'section') { const ownPagespecDir = join(moduleDir, 'pagespecs') for (const file of pagespecFiles(baRoot)) { const before = staging.read(file) if (before === null) continue const ownModule = dirname(file) === ownPagespecDir const r = spec.level === 'module' ? renamePagespecRefs(before, { app, oldMod: code, newMod: to.code, ownModule }) : renamePagespecRefs(before, { app, mod, oldSec: code, newSec: to.code, ownModule }) if (r.error) warnings.push(`${rel(baRoot, file)} : bloc json illisible (${r.error}) — références non réécrites, à corriger à la main.`) if (r.count > 0 && staging.set(file, r.content)) count(plan.pagespecRewrites, file, r.count) } } // 5. the node's own index.md (anchor + title) — written at the OLD path, moved with the folder const ownIndexPath = join(nodeDir, 'index.md') const ownIndex = staging.read(ownIndexPath) if (ownIndex === null) { warnings.push(`${oldPath}/index.md absent — le dossier est renommé mais aucune ancre ne porte previousCodes=${navFormOf(code)} (le seed prod fera deactivate+insert).`) } else { let after = updateNodeAnchor(ownIndex, { code: to.code, addPreviousCode: navFormOf(code) }) const titleRe = /^#\s+(.+?)(\s+[—–-]\s+(.+))?\s*$/m const t = titleRe.exec(after) if (t) { const label = to.label ?? t[3]?.trim() ?? node.label after = after.replace(t[0], `# ${to.code} — ${label}`) } staging.set(ownIndexPath, after) } plan.directoriesToMove.push({ from: rel(baRoot, nodeDir), to: rel(baRoot, newDir) }) // 6. parent ## Enfants const parentIndexPath = join(parentDir, 'index.md') const parentIndex = staging.read(parentIndexPath) if (parentIndex !== null) { const spliced = spliceEnfants(parentIndex, { op: 'rename', code, newCode: to.code, label: to.label }) if (spliced.changed) staging.set(parentIndexPath, spliced.content) else warnings.push(`${rel(baRoot, parentIndexPath)} : aucune ligne \`- [${code}](./${code}/index.md)\` dans ## Enfants — rien à réécrire (liste à compléter à la main).`) } // 7. residual scan — the old code as a whole token on code-ish lines, module scope (app scope for a module) const scanRoot = spec.level === 'module' ? join(baRoot, app) : moduleDir const scanFiles = new Set([...walkMd(scanRoot), ...(spec.level === 'module' ? pagespecFiles(baRoot) : [])]) for (const file of [...scanFiles].sort()) { const content = staging.read(file) if (content === null) continue for (const hit of residualTokenScan(content, code)) { if (hit.includes('previousCodes=')) continue plan.residual.push(`${rel(baRoot, file)}:${hit}`) if (plan.residual.length >= 60) break } if (plan.residual.length >= 60) break } if (plan.residual.length > 0) { warnings.push(`${plan.residual.length} occurrence(s) résiduelle(s) de « ${code} » hors des listes de tokens réécrites (plan.residual) — à relire : liens relatifs de rollups, prose, champs non couverts.`) } // 8. report-only — other writers own these const sourcesIndex = join(spec.projectPath ?? dirname(baRoot), '.smartstack', 'sources', 'index.json') const sourcesAlt = join(dirname(baRoot), 'sources', 'index.json') if (existsSync(sourcesIndex) || existsSync(sourcesAlt)) { plan.reportOnly.push({ what: `Registre des sources — les \`scopes\` nomment encore ${oldPath}`, where: rel(baRoot, existsSync(sourcesIndex) ? sourcesIndex : sourcesAlt), route: 're-pointer via /ba-create-sources (SRC-002 avertit sur chaque scope orphelin)' }) } if (isDir(join(baRoot, '_plan'))) { plan.reportOnly.push({ what: 'Plans BA / dev (`_plan/*`) — clés de modules périmées', where: '_plan/', route: 'relancer /ba-create-ba-order puis /ba-create-plan-development' }) } if (walkMd(scanRoot, new Set(['node_modules'])).some((f) => basename(dirname(f)) === '_audit')) { plan.reportOnly.push({ what: 'Verdicts `_audit/*.md` périmés', where: rel(baRoot, scanRoot), route: `relancer audit-ba (npx --prefer-offline tsx ${CLI_PATHS.auditBa} --spec '{"baRoot":"${spec.baRoot}"}')` }) } const snapshot = join(moduleDir, '.run-snapshot.json') if (plan.pagespecRewrites.length > 0 && existsSync(snapshot)) { plan.reportOnly.push({ what: `${plan.pagespecRewrites.length} pagespec(s) réécrit(s) — compute-page-diff les verra « changés »`, where: rel(baRoot, snapshot), route: `${plan.pagespecRewrites.length} page(s) régénérée(s) en Phase 3 au prochain /ba-develop (attendu)` }) } const coreSeed = join(spec.projectPath ?? dirname(dirname(baRoot)), '.smartstack', 'core-seed') if (isDir(coreSeed)) { plan.reportOnly.push({ what: 'Navigation seedée (core-seed) — l\'ancien code vit en base', where: rel(baRoot, coreSeed), route: `previousCodes=${navFormOf(code)} est le canal : Phase 0 de /ba-develop le transporte, derive-seed-delta émet un UPDATE (GUID conservé)` }) nextSteps.push(`Module développé : /ba-develop ${app}/${spec.level === 'module' ? to.code : mod} ré-entrera (Phase 0 nav + Phase 3 pages).`) } if (plan.pagespecRewrites.length > 0) nextSteps.push(`Pagespecs touchés : relancer /ba-audit-prd sur ${app}/${spec.level === 'module' ? to.code : mod}.`) nextSteps.push(`Vérifier la cohérence aval : npx --prefer-offline tsx ${CLI_PATHS.reconcileMenu} --spec '{"baRoot":"${spec.baRoot}","dryRun":true}' doit répondre « cohérent ».`) plan.filesToCreate = staging.created().map((p) => rel(baRoot, p)) plan.filesToModify = staging.modified().map((p) => rel(baRoot, p)) // --- write -------------------------------------------------------------------------------- if (spec.mode !== 'write') return report() staging.flush() renameSync(nodeDir, newDir) return report({ applied: { filesCreated: plan.filesToCreate, filesModified: plan.filesToModify, directoriesMoved: plan.directoriesToMove, directoriesRemoved: [], }, }) }