/** * cli:menu-node — delete.ts * * Delete ONE node (module / section / resource) DETERMINISTICALLY: the * downstream blocks and body references of its long codes (module wildcard = * every section), the human permission rows of the module's rbac.md, for a * module the `depends=` anchors + `## Dépendances` lines of the other modules, * the folder, and the parent's `## Enfants` line. After it, * `reconcile-menu --dryRun` finds no ghost — a similar unreferenced sibling * can no longer turn the deletion into a mis-detected RENAME. * * Never automatic: pagespecs referencing a deleted SECTION (they live at * module level and may carry `uiDesign` / `lifecycle` written by /ui-design) * and `cross-module` relations pointing at a deleted MODULE are REPORTED — * both are model decisions, not cleanup. The confirmation gate is the skill's * AskUserQuestion between `check` and `write`. */ import { existsSync, rmSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { PAGESPEC_JSON_BLOCK_RE, countCrossModuleScopes, countPermissionPaths, countScopeCodes, findNode, navFormOf, removeCodesFromText, removePermissionPaths, sectionFolderToCode, spliceDependances, spliceEnfants, updateNodeAnchor, type CodeScope, type MenuTree, } from '../../../../lib/ba-menu-tree.js' import { CLI_PATHS, Staging, accentedPath, descendantsOf, downstreamDocs, hasMachineBlocks, isAuthoredRbac, isDir, pagespecFiles, readTextOrNull, rel, siblingsOf, walkMd } from './common.js' import { emptyPlan, type Blocked, type MenuNodeInput, type MenuNodeReport, type ResolvedScope } from './types.js' export function runDelete(spec: MenuNodeInput, scope: ResolvedScope, tree: MenuTree): MenuNodeReport { const { baRoot, parentPath, parentDir } = scope const code = spec.code const nodeDir = join(parentDir, code) const path = [...parentPath, code].join('/') const plan = emptyPlan() const blocked: Blocked[] = [] const warnings: string[] = [] const nextSteps: string[] = [] const report = (extra: Partial = {}): MenuNodeReport => ({ op: 'delete', mode: spec.mode, level: spec.level, node: { code, path, dir: nodeDir }, blocked, warnings, plan, applied: null, noop: false, nextSteps, ...extra, }) if (spec.level === 'application') { blocked.push({ code: 'app-delete-unsupported', reason: 'Supprimer une APPLICATION retire tous ses modules, leurs codes, leurs pagespecs et les `scopes` du registre des sources — hors périmètre de ce CLI.', route: 'Supprimer le dossier à la main après confirmation, puis /ba-reconcile-menu, /ba-create-sources (scopes) et les audits.', }) return report() } // --- code scope ----------------------------------------------------------------------- const app = parentPath[0]! const mod = spec.level === 'module' ? code : parentPath[1]! const codeScope: CodeScope = { app, mod } if (spec.level === 'section') codeScope.sec = sectionFolderToCode(code) if (spec.level === 'resource') { codeScope.sec = sectionFolderToCode(parentPath[2]!) codeScope.res = sectionFolderToCode(code) } const moduleDir = spec.level === 'module' ? nodeDir : join(baRoot, app, mod) const node = findNode(tree, [...parentPath, code]) const siblings = siblingsOf(tree, parentPath) const stranded = downstreamDocs(baRoot).reduce((n, f) => n + countScopeCodes(readTextOrNull(f) ?? '', codeScope), 0) if (!node) { if (stranded === 0) { warnings.push(`${path} n'existe plus et aucun code aval ne le référence — suppression déjà effective.`) return report({ noop: true }) } blocked.push({ code: 'node-not-found', reason: `Le nœud ${path} n'existe pas dans l'arbre mais ${stranded} code(s) aval le référencent encore.`, route: `Nettoyage des orphelins : npx --prefer-offline tsx ${CLI_PATHS.reconcileMenu} --spec '{"baRoot":"${spec.baRoot}","dryRun":true}'`, candidates: siblings.map((s) => s.code), }) return report() } // --- warnings ------------------------------------------------------------------------- const descendants = descendantsOf(node) if (descendants.length > 0) warnings.push(`${descendants.length} sous-nœud(s) supprimé(s) avec ${path} : ${descendants.map((d) => d.path.slice(parentPath.length).join('/')).join(', ')}.`) if (spec.level === 'section' && siblings.length === 1) { warnings.push(`Dernière section du module ${app}/${mod} : le module devient VIDE (MENU-004 / SEC-001 err au prochain audit) — supprimer le module ou ajouter une section.`) } if (spec.level === 'module') { const inside = walkMd(nodeDir, new Set(['node_modules'])) const pagespecs = inside.filter((f) => basename(dirname(f)) === 'pagespecs').length const prd = inside.filter((f) => /^prd(\..*)?\.md$/.test(basename(f))).length if (pagespecs + prd > 0) warnings.push(`Le dossier du module emporte ${pagespecs} pagespec(s) et ${prd} document(s) PRD — ils disparaissent avec lui (git est le filet).`) } const staging = new Staging() const count = (list: Array<{ file: string; count: number }>, file: string, n: number): void => { if (n > 0) list.push({ file: rel(baRoot, file), count: n }) } // 1. downstream docs — blocks + body references (files INSIDE the node folder go with it) for (const file of downstreamDocs(baRoot)) { if (file.startsWith(nodeDir + '\\') || file.startsWith(nodeDir + '/')) continue const before = staging.read(file) if (before === null) continue const total = countScopeCodes(before, codeScope) if (total === 0) continue const headingRe = new RegExp(`^###\\s+(?:UC|SCR|BR|RBAC)-${[codeScope.app, codeScope.mod, codeScope.sec, codeScope.res].filter(Boolean).join('-')}(?:-|\\b)`, 'gm') const headings = (before.match(headingRe) ?? []).length plan.downstreamCodes.push({ file: rel(baRoot, file), headings, bodyRefs: total - headings }) const after = removeCodesFromText(before, app, mod, codeScope.sec, codeScope.res) if (staging.set(file, after)) count(plan.codeRewrites, file, total) } // 2. rbac.md human rows (section / resource — a module's rbac.md goes with the folder) if (spec.level !== 'module') { const rbacPath = join(moduleDir, 'rbac.md') const rbacBefore = staging.read(rbacPath) if (rbacBefore !== null) { const permScope = 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, removePermissionPaths(rbacBefore, permScope))) count(plan.permissionPathRewrites, rbacPath, n) if (hasMachineBlocks(rbacBefore) || isAuthoredRbac(rbacBefore)) { plan.reportOnly.push({ what: 'Blocs machine de rbac.md — chemins du nœud supprimé encore listés', where: rel(baRoot, rbacPath), route: `relancer derive-permission-floor + derive-lookup-grants en mode derive sur ${app}/${mod}` }) nextSteps.push( `npx --prefer-offline tsx ${CLI_PATHS.permissionFloor} --spec '{"baRoot":"${spec.baRoot}","app":"${app}","module":"${mod}","mode":"derive"}' puis npx --prefer-offline tsx ${CLI_PATHS.lookupGrants} --spec '{"baRoot":"${spec.baRoot}","app":"${app}","module":"${mod}","mode":"derive"}'.`, ) } } } // 3. module — depends= anchors + ## Dépendances lines of the other modules; cross-module relations reported 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) { let after = before let n = 0 const anchorDepends = m.anchor?.depends ?? [] if (anchorDepends.some((d) => d.toLowerCase() === key.toLowerCase())) { after = updateNodeAnchor(after, { depends: anchorDepends.filter((d) => d.toLowerCase() !== key.toLowerCase()) }) n += 1 } if (m.path[0] === app) { const dep = spliceDependances(after, { op: 'remove', 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 n = countCrossModuleScopes(staging.read(entitePath) ?? '', app, code) if (n > 0) { plan.reportOnly.push({ what: `${n} relation(s) \`scope cross-module (${key})\` vers le module supprimé`, where: rel(baRoot, entitePath), route: 'décision de modèle : retirer la relation ou la re-pointer (/ba-change kind=attribute / entity), puis /ba-audit-data-model' }) } } } const anyAuthoredRbac = tree.all.some((n) => n.level === 'module' && n.dir !== nodeDir && isAuthoredRbac(readTextOrNull(join(n.dir, 'rbac.md')))) if (anyAuthoredRbac) nextSteps.push(`Relancer derive-lookup-grants en mode derive sur chaque module dont le rbac.md est rédigé (des lookups dérivés peuvent viser ${key}).`) } // 4. pagespecs referencing a deleted SECTION — reported, never deleted if (spec.level === 'section') { const ownPagespecDir = join(moduleDir, 'pagespecs') for (const file of pagespecFiles(baRoot).filter((f) => dirname(f) === ownPagespecDir)) { const content = readTextOrNull(file) if (content === null) continue const m = PAGESPEC_JSON_BLOCK_RE.exec(content) let hits = false if (m) { try { const block = JSON.parse(m[1]!) as Record hits = typeof block.section === 'string' && navFormOf(block.section) === navFormOf(code) } catch { hits = content.includes(`"${code}"`) } } if (hits) plan.reportOnly.push({ what: 'Pagespec de la section supprimée (peut porter uiDesign / lifecycle de /ui-design)', where: rel(baRoot, file), route: 'décision utilisateur : supprimer le fichier à la main ou le re-pointer ; puis /ba-audit-prd' }) } } // 5. parent ## Enfants const parentIndexPath = join(parentDir, 'index.md') const parentIndex = staging.read(parentIndexPath) if (parentIndex !== null) { const spliced = spliceEnfants(parentIndex, { op: 'remove', code }) if (spliced.changed) staging.set(parentIndexPath, spliced.content) } plan.directoriesToRemove.push(rel(baRoot, nodeDir)) // 6. report-only if (walkMd(moduleDir, new Set(['node_modules'])).some((f) => basename(dirname(f)) === '_audit') || isDir(join(baRoot, app, '_audit'))) { plan.reportOnly.push({ what: 'Verdicts `_audit/*.md` périmés', where: rel(baRoot, spec.level === 'module' ? join(baRoot, app) : moduleDir), route: `relancer audit-ba (npx --prefer-offline tsx ${CLI_PATHS.auditBa} --spec '{"baRoot":"${spec.baRoot}"}')` }) } if (spec.level === 'module') { const sourcesIndex = join(spec.projectPath ?? dirname(dirname(baRoot)), '.smartstack', 'sources', 'index.json') if (existsSync(sourcesIndex)) plan.reportOnly.push({ what: `Registre des sources — des \`scopes\` peuvent nommer ${path}`, where: rel(baRoot, sourcesIndex), route: 're-pointer via /ba-create-sources (SRC-002)' }) if (isDir(join(baRoot, '_plan'))) plan.reportOnly.push({ what: 'Plans BA / dev (`_plan/*`) — module encore planifié', where: '_plan/', route: 'relancer /ba-create-ba-order puis /ba-create-plan-development' }) } 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() rmSync(nodeDir, { recursive: true, force: true }) return report({ applied: { filesCreated: plan.filesToCreate, filesModified: plan.filesToModify, directoriesMoved: [], directoriesRemoved: plan.directoriesToRemove, }, }) }