/** * cli:menu-node — add.ts * * Add ONE node: folder + canonical `index.md` + the six placeholders, and a * LINE-LEVEL splice of the parent's `## Enfants` — the parent's anchor * (`depends=`, `previousCodes=`), Contexte, `**Sources**` and Hors-périmètre * are byte-identical (what the full re-Write used to lose). The first * application also creates the tree root. */ import { existsSync } from 'node:fs' import { join } from 'node:path' import { EMPTY_HP_MARKER, foldMenuCode, placeholderDocs, renderIndexMd, spliceEnfants, type MenuTree, } from '../../../../lib/ba-menu-tree.js' import { CLI_PATHS, Staging, accentedPath, checkBound, checkNodeShape, isAuthoredRbac, nodeWarnings, readTextOrNull, rel, siblingsOf } from './common.js' import { emptyPlan, type Blocked, type MenuNodeInput, type MenuNodeReport, type ResolvedScope } from './types.js' function renderRootIndex(project: MenuNodeInput['project']): string { const out = ['', `# ${project?.label ?? 'Projet'}`, '', '## Contexte'] if (project?.contexte && project.contexte.trim() !== '') out.push(project.contexte.trim()) out.push('', '## Hors-périmètre') const bullets = (project?.horsPerimetre ?? []).map((s) => s.trim()).filter(Boolean) if (bullets.length === 0) out.push(EMPTY_HP_MARKER) else for (const b of bullets) out.push(`- ${b}`) out.push('', '## Enfants', '') return out.join('\n') } export function runAdd(spec: MenuNodeInput, scope: ResolvedScope, tree: MenuTree): MenuNodeReport { const { baRoot, parentPath, parentDir } = scope const code = spec.code const label = spec.label! 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: 'add', mode: spec.mode, level: spec.level, node: { code, label, path, dir: nodeDir }, blocked, warnings, plan, applied: null, noop: false, nextSteps, ...extra, }) // --- idempotence: the exact node already exists -------------------------------- const siblings = siblingsOf(tree, parentPath) const exact = siblings.find((s) => s.code === code) if (exact && exact.indexRaw !== null) { warnings.push(`Le nœud ${path} existe déjà (index.md présent) — rien à écrire. Pour le renommer ou le re-contextualiser : op=rename / Edit de son index.md.`) return report({ noop: true }) } // --- blocked --------------------------------------------------------------------- blocked.push(...checkNodeShape({ level: spec.level, code, label, contexte: spec.contexte, extension: spec.extension, parentPath })) const folded = foldMenuCode(code) const dup = siblings.find((s) => s.code !== code && foldMenuCode(s.code) === folded) if (dup) { blocked.push({ code: 'duplicate-sibling', reason: `Un frère « ${dup.code} » porte déjà ce code à la casse/accent près (SEC-002 / MENU-002).`, route: 'Réutiliser le nœud existant, ou choisir un code réellement distinct.', candidates: siblings.map((s) => s.code), }) } if (!exact) { const bound = checkBound(tree, spec.level, parentPath) if (bound) blocked.push(bound) } // The parent must carry an index.md to receive the child link (root excepted — created). const parentIndexPath = join(parentDir, 'index.md') const parentIndex = readTextOrNull(parentIndexPath) if (parentPath.length > 0 && parentIndex === null) { blocked.push({ code: 'parent-index-missing', reason: `Le parent ${parentPath.join('/')} n'a pas d'index.md — impossible d'y inscrire l'enfant.`, route: 'Créer le parent proprement (op=add sur le niveau au-dessus) avant d\'ajouter ses enfants.', }) } warnings.push(...nodeWarnings(tree, { level: spec.level, code, label, contexte: spec.contexte, parentPath })) if (blocked.length > 0) return report() // --- plan --------------------------------------------------------------------------- const staging = new Staging() if (parentPath.length === 0 && parentIndex === null) { staging.set(parentIndexPath, renderRootIndex(spec.project)) if (!scope.freshTree) warnings.push('La racine `.smartstack/ba/index.md` manquait — recréée (level=project).') if (!spec.project) warnings.push('Racine créée sans `project` : compléter son `## Contexte` et son `## Hors-périmètre` (checkpoint hors-périmètre du tour 1).') } const indexPath = join(nodeDir, 'index.md') if (readTextOrNull(indexPath) === null) { const horsPerimetre = spec.horsPerimetre ?? (spec.level === 'application' || spec.level === 'module' ? [] : undefined) staging.set( indexPath, renderIndexMd({ level: spec.level, code, label, contexte: spec.contexte, horsPerimetre, sources: spec.sources }), ) } for (const doc of placeholderDocs(spec.level, code)) { if (accentedPath(nodeDir, doc.file) === null) staging.set(join(nodeDir, doc.file), doc.content) } if (exact) warnings.push(`Le dossier ${path} existait sans index.md — complété (index.md + placeholders manquants).`) const parentCurrent = staging.read(parentIndexPath) ?? '' const spliced = spliceEnfants(parentCurrent, { op: 'add', code, label }) if (spliced.changed) staging.set(parentIndexPath, spliced.content) plan.filesToCreate = staging.created().map((p) => rel(baRoot, p)) plan.filesToModify = staging.modified().map((p) => rel(baRoot, p)) // --- nextSteps ------------------------------------------------------------------------ if ((spec.level === 'section' || spec.level === 'resource') && parentPath.length >= 2) { const moduleDir = join(baRoot, parentPath[0]!, parentPath[1]!) if (isAuthoredRbac(readTextOrNull(join(moduleDir, 'rbac.md')))) { const moduleKey = `${parentPath[0]}/${parentPath[1]}` nextSteps.push( `rbac.md de ${moduleKey} est rédigé : relancer derive-permission-floor pour refléter le nouveau nœud dans le miroir ba:rbac-floor (RBAC-009 sinon) — npx --prefer-offline tsx ${CLI_PATHS.permissionFloor} --spec '{"baRoot":"${spec.baRoot}","app":"${parentPath[0]}","module":"${parentPath[1]}","mode":"derive"}'`, ) } } nextSteps.push(`Auditer le menu après l'écriture : npx --prefer-offline tsx ${CLI_PATHS.auditBa} --spec '{"baRoot":"${spec.baRoot}","dimensions":["menu","sections"]}' (ou /ba-audit-menu + /ba-audit-sections).`) if (spec.level === 'section' || spec.level === 'resource') { nextSteps.push('Le nouveau nœud n\'a que des placeholders : enrichir avec /ba-loop sur le module (ou les /ba-create-* phase par phase).') } // --- write -------------------------------------------------------------------------------- if (spec.mode !== 'write') return report() staging.flush() if (!existsSync(nodeDir)) { // No placeholder was needed (all six present) and index.md existed — cannot happen after the idempotence gate, kept as a guard. warnings.push(`Aucun fichier à créer sous ${path}.`) } return report({ applied: { filesCreated: plan.filesToCreate, filesModified: plan.filesToModify, directoriesMoved: [], directoriesRemoved: [], }, }) }