/** * cli:menu-node — common.ts * * Shared by add / rename / delete: the file walkers over the BA tree, the * staged-write buffer (plan first, write only in mode=write), the node * checks that mirror the MECHANICAL audit rules (MENU-003/004/006/007/008, * SEC-002/005, XAPP-001 — constants from lib/ba-menu-tree, the single * source audit-ba reads too) and the contiguous CLI paths the nextSteps cite * (a path assembled from fragments escapes the installer's * `skills/business-analyse/` → `skills/ba-` rewrite). */ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { basename, dirname, join, relative } from 'node:path' import { AMBIGUOUS_SECTION_NAMES, CONFIG_APP_RE, CONTEXT_MIN_CHARS, EXTENSION_DECLARATION_RE, FORBIDDEN_LABEL_CHARS_RE, FORBIDDEN_SECTION_SUFFIX_RE, MENU_BOUNDS, TECHNICAL_FEATURES_RE, XAPP_SECTION_PATTERNS, codeFormatFor, foldMenuCode, type MenuNode, type MenuTree, } from '../../../../lib/ba-menu-tree.js' import { matchBuiltinApp, matchBuiltinModule } from '../../../../lib/platform-catalog.js' import type { Blocked, MenuNodeLevel } from './types.js' /** Contiguous literals — the installer re-points `skills/business-analyse/` at the deployed `skills/ba-`. */ export const CLI_PATHS = { permissionFloor: 'skills/business-analyse/create-rbac/cli/derive-permission-floor/index.ts', lookupGrants: 'skills/business-analyse/create-rbac/cli/derive-lookup-grants/index.ts', reconcileMenu: 'skills/business-analyse/reconcile-menu/cli/reconcile-menu/index.ts', auditBa: 'skills/business-analyse/audit-run/cli/audit-ba/index.ts', } as const // --------------------------------------------------------------------------- // Paths & walkers // --------------------------------------------------------------------------- export function rel(baRoot: string, abs: string): string { return relative(baRoot, abs).replace(/\\/g, '/') } const DOWNSTREAM_NAMES: ReadonlySet = new Set(['use-case.md', 'screen.md', 'règles-métier.md', 'rbac.md'].map((n) => n.normalize('NFC'))) /** Every `.md` under `dir`, recursively — `_audit/`, dot-folders and node_modules skipped. */ export function walkMd(dir: string, skipDirNames: ReadonlySet = new Set(['_audit', 'node_modules'])): string[] { const out: string[] = [] const visit = (d: string): void => { let entries try { entries = readdirSync(d, { withFileTypes: true }) } catch { return } for (const e of entries) { if (e.isDirectory()) { if (e.name.startsWith('.') || skipDirNames.has(e.name)) continue visit(join(d, e.name)) } else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) { out.push(join(d, e.name)) } } } visit(dir) return out.sort() } /** The four downstream docs carrying long codes (NFC/NFD filenames both accepted). */ export function downstreamDocs(baRoot: string): string[] { return walkMd(baRoot).filter((f) => DOWNSTREAM_NAMES.has(basename(f).normalize('NFC'))) } /** Every pagespec file (the `pagespecs/` folder of any module). */ export function pagespecFiles(baRoot: string): string[] { return walkMd(baRoot).filter((f) => basename(dirname(f)) === 'pagespecs') } /** The existing path of an accented doc (NFC or NFD spelling), or null. */ export function accentedPath(dir: string, nfcName: string): string | null { for (const name of [nfcName, nfcName.normalize('NFD')]) { const p = join(dir, name) try { if (existsSync(p) && statSync(p).isFile()) return p } catch { /* treated as absent */ } } return null } export function readTextOrNull(path: string): string | null { try { if (existsSync(path) && statSync(path).isFile()) return readFileSync(path, 'utf8') } catch { /* treated as absent */ } return null } export function isDir(path: string): boolean { try { return existsSync(path) && statSync(path).isDirectory() } catch { return false } } /** rbac.md carries authored content (human rows or a machine mirror) — a placeholder does not. */ export function isAuthoredRbac(content: string | null): boolean { if (content === null) return false return /^\|\s*BA-/m.test(content) || //.test(content) || //.test(content) } export function hasMachineBlocks(content: string | null): boolean { return content !== null && (//.test(content) || //.test(content)) } // --------------------------------------------------------------------------- // Staged writes — everything is computed in memory first; mode=write flushes // --------------------------------------------------------------------------- export class Staging { private readonly files = new Map() /** Current content: staged version first, then disk. */ read(path: string): string | null { const staged = this.files.get(path) if (staged) return staged.after return readTextOrNull(path) } /** Stage a new content; a no-change stage is ignored. Returns true when something changed. */ set(path: string, after: string): boolean { const before = this.files.get(path)?.before ?? readTextOrNull(path) if (before === after) return false this.files.set(path, { before, after }) return true } paths(): string[] { return [...this.files.keys()].sort() } created(): string[] { return this.paths().filter((p) => this.files.get(p)!.before === null) } modified(): string[] { return this.paths().filter((p) => this.files.get(p)!.before !== null) } flush(): void { for (const [path, { after }] of this.files) { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, after, 'utf8') } } } // --------------------------------------------------------------------------- // Tree helpers // --------------------------------------------------------------------------- export function siblingsOf(tree: MenuTree, parentPath: string[]): MenuNode[] { if (parentPath.length === 0) return tree.apps let level: MenuNode[] = tree.apps let parent: MenuNode | undefined for (const seg of parentPath) { parent = level.find((n) => n.code.toLowerCase() === seg.toLowerCase()) if (!parent) return [] level = parent.children } return level } export function boundFor(level: MenuNodeLevel): { max: number; label: string } { switch (level) { case 'application': return { max: MENU_BOUNDS.applications, label: 'applications par projet' } case 'module': return { max: MENU_BOUNDS.modulesPerApp, label: 'modules par application' } case 'section': return { max: MENU_BOUNDS.sectionsPerModule, label: 'sections par module' } default: return { max: MENU_BOUNDS.resourcesPerSection, label: 'ressources par section' } } } export function descendantsOf(node: MenuNode): MenuNode[] { const out: MenuNode[] = [] const visit = (n: MenuNode): void => { for (const c of n.children) { out.push(c) visit(c) } } visit(node) return out } // --------------------------------------------------------------------------- // Node checks — mirrors of the MECHANICAL audit rules (blocked) and warnings // --------------------------------------------------------------------------- export interface NodeCheckInput { level: MenuNodeLevel code: string label?: string contexte?: string extension?: boolean /** Codes from the application down to the PARENT. */ parentPath: string[] } export function checkNodeShape(input: NodeCheckInput): Blocked[] { const blocked: Blocked[] = [] const { level, code, label } = input const format = codeFormatFor(level) if (!format.test(code)) { blocked.push({ code: 'code-format', reason: `« ${code} » ne respecte pas le format ${level === 'application' || level === 'module' ? 'UPPER_UNDERSCORE (^[A-Z][A-Z0-9_]*$)' : 'lower-kebab ASCII (^[a-z][a-z0-9-]*$)'} du niveau ${level}.`, route: 'Le dossier EST le code : ASCII sans accent, le label garde la langue et les accents.', }) } if (label !== undefined && FORBIDDEN_LABEL_CHARS_RE.test(label)) { blocked.push({ code: 'label-forbidden-chars', reason: `Le label « ${label} » contient un des caractères interdits & / \\ | < > " — il regroupe deux concepts.`, route: 'Scinder en deux nœuds ou choisir un nom unificateur.', }) } // MENU-006 — codes identical across hierarchy levels break the generated routes. const folded = foldMenuCode(code) const ancestor = input.parentPath.find((p) => foldMenuCode(p) === folded) if (ancestor) { blocked.push({ code: 'hierarchy-code-collision', reason: `Le code « ${code} » est déjà celui de l'ancêtre « ${ancestor} » (MENU-006 : la navigation générée ne peut pas construire des routes en collision).`, route: 'Choisir un code distinct à chaque niveau (`list` ou `dashboard` par défaut pour une section).', }) } if (level === 'application') { if (CONFIG_APP_RE.test(code) || (label !== undefined && CONFIG_APP_RE.test(label))) { blocked.push({ code: 'config-is-a-module', reason: `« ${code} » : la configuration est un MODULE, jamais une application (MENU-003).`, route: 'Ajouter un module de paramétrage dans chaque application concernée.' }) } if (TECHNICAL_FEATURES_RE.test(code) || (label !== undefined && TECHNICAL_FEATURES_RE.test(label))) { blocked.push({ code: 'technical-feature', reason: `« ${code} » : feature technique transverse fournie par la plateforme — jamais une application utilisateur (MENU-003).`, route: 'Retirer le nœud ; la plateforme livre Auth/SSO, Notifications, Workflows, Data export.' }) } const builtin = matchBuiltinApp(code) ?? (label !== undefined ? matchBuiltinApp(label) : undefined) if (builtin && !builtin.extendable) { blocked.push({ code: 'platform-app-not-extendable', reason: `« ${code} » correspond à l'app plateforme « ${builtin.code} », gérée par la plateforme — aucun module client ne s'y ajoute.`, route: 'Choisir un code + label DISTINCTS si le besoin est réellement différent.' }) } else if (builtin && !(input.extension && input.contexte !== undefined && EXTENSION_DECLARATION_RE.test(input.contexte))) { blocked.push({ code: 'platform-app-collision', reason: `« ${code} » duplique l'app plateforme intégrée « ${builtin.code} » (${builtin.label}) — ÉTENDRE, jamais recréer (MENU-003).`, route: `Relancer avec extension:true ET un Contexte contenant « Extension de l'application plateforme \`${builtin.code}\` (ApplicationId ${builtin.guid}) — ces modules s'ajoutent aux modules livrés » ; proposer UNIQUEMENT les modules delta sous ce nœud.`, }) } } if (level === 'module') { if (TECHNICAL_FEATURES_RE.test(code) || (label !== undefined && TECHNICAL_FEATURES_RE.test(label))) { blocked.push({ code: 'technical-feature', reason: `« ${code} » : feature technique transverse fournie par la plateforme — jamais un module utilisateur (MENU-003).`, route: 'Retirer le nœud.' }) } const hit = matchBuiltinModule(code) ?? (label !== undefined ? matchBuiltinModule(label) : undefined) if (hit) { blocked.push({ code: 'platform-module-collision', reason: `« ${code} » recrée le module distinctif « ${hit.module.code} » de l'app plateforme « ${hit.app.code} » (MENU-003).`, route: `Ne pas recréer : référencer le module livré (/${hit.app.code}/${hit.module.code}) et ne modéliser que le delta.`, }) } const parentApp = input.parentPath[0] const parentBuiltin = parentApp ? matchBuiltinApp(parentApp) : undefined if (parentBuiltin && !parentBuiltin.extendable) { blocked.push({ code: 'platform-app-not-extendable', reason: `L'application « ${parentApp} » est l'app plateforme « ${parentBuiltin.code} », gérée par la plateforme — aucun module client ne s'y ajoute.`, route: 'Déposer le module sous une application métier du client.' }) } } if (level === 'section' || level === 'resource') { if (CONFIG_APP_RE.test(code)) { blocked.push({ code: 'settings-section', reason: `« ${code} » : les paramètres sont un MODULE, jamais une section (interdiction n°2 de /ba-create-menu).`, route: 'Créer un module de paramétrage dans l\'application.' }) } if (FORBIDDEN_SECTION_SUFFIX_RE.test(code)) { blocked.push({ code: 'action-or-representation-suffix', reason: `« ${code} » : un suffixe -detail/-edit/-create/-form est une ACTION, -board/-workflow/-pipeline une REPRÉSENTATION — jamais un code de section.`, route: 'Une action est un bouton ; un kanban/board est un second écran de la section *-list (phase écrans).', }) } } return blocked } /** MENU-004 bounds — apps ≤ 4 · modules ≤ 8/app · sections ≤ 6/module · resources ≤ 8/section. */ export function checkBound(tree: MenuTree, level: MenuNodeLevel, parentPath: string[]): Blocked | null { const { max, label } = boundFor(level) const existing = siblingsOf(tree, parentPath).length if (existing + 1 > max) { return { code: 'bound-exceeded', reason: `${existing} ${label} déjà présents — la borne est ${max} (SKILL.md § Familiar-domain trap : STOP et proposer un découpage).`, route: 'Fusionner, requalifier en filtre/ressource, ou répartir sur plusieurs tours.', } } return null } /** Warnings calqués sur la sévérité de l'audit — jamais bloquants. */ export function nodeWarnings(tree: MenuTree, input: NodeCheckInput): string[] { const warnings: string[] = [] const { level, code, contexte } = input const folded = foldMenuCode(code) if (level === 'application' || level === 'module') { if (contexte !== undefined && contexte.trim() !== '' && contexte.trim().length < CONTEXT_MIN_CHARS) { warnings.push(`MENU-007 : Contexte de ${contexte.trim().length} caractères (< ${CONTEXT_MIN_CHARS}) — 3-5 phrases attendues (QUI / QUOI / LIMITES).`) } } if (level === 'section' && (contexte === undefined || contexte.trim() === '')) { warnings.push('MENU-007 : Contexte absent sur une section — toléré (warn), mais le PRD n\'aura aucun ancrage métier pour ce nœud.') } if ((level === 'section' || level === 'resource') && AMBIGUOUS_SECTION_NAMES.has(folded) && !CONFIG_APP_RE.test(code)) { warnings.push(`SEC-005 : « ${code} » est un nom de section ambigu (settings/options/divers/…) — préférer le sujet métier.`) } if ((level === 'module' || level === 'section') && XAPP_SECTION_PATTERNS.includes(folded)) { const app = input.parentPath[0] const elsewhere = tree.all.filter((n) => (n.level === 'module' || n.level === 'section') && foldMenuCode(n.code) === folded && n.path[0] !== app).map((n) => n.path.join('/')) if (elsewhere.length > 0) { warnings.push(`XAPP-001 : motif « ${code} » déjà présent dans une autre application (${elsewhere.join(', ')}) — vrai doublon fonctionnel ou réutilisation légitime ?`) } } return warnings }