/** * lib/ba-menu-tree.ts — THE shared contract of the `.smartstack/ba/` MENU TREE * as a machine object: node anchors, node codes inside downstream codes, * permission paths, pagespec references, the parent `## Enfants` list, the * six placeholder docs and the canonical `index.md`. * * Why it lives in lib/ (and not beside one CLI): two skills write or repair * the tree — `/ba-create-menu` (`cli/menu-node`: add / rename / delete a node * DETERMINISTICALLY, knowing from → to) and `/ba-reconcile-menu` * (`cli/reconcile-menu`: repair the downstream docs after a HAND-MADE edit, * guessing the rename). The installer flattens `business-analyse//` to * `ba-/` and only rewrites cross-skill relative imports for an * ENUMERATED list of targets (src/lib/installer.ts) — so the code both skills * need is here, imported as `../../../../lib/ba-menu-tree.js`, and * reconcile-menu re-exports what its tests import from `../clean.js`. * * Also the single source of the MECHANICAL menu/section audit constants that * audit-ba (`rules/menu.ts`, `rules/sections.ts`) and the menu-node `check` * mode both apply: a node the CLI accepts is a node the audit accepts. * * Code forms (the tree has TWO spellings of the same node code): * - folder / anchor form — `PIPELINE` (app, module: UPPER_UNDERSCORE), * `exchange-history` (section, resource: lower-kebab); * - long-code form — the segment inside `UC-APP-MOD-SEC[-RES]-NNN`: * `EXCHANGE_HISTORY` (`sectionFolderToCode`); * - nav form — `exchange-history` / `pipeline`: what the core-seed spec, the * permission paths and the pagespecs carry (`navFormOf`). */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { normalizeEntityToken } from './core-catalog.js' // --------------------------------------------------------------------------- // Levels, code formats, bounds (create-menu/SKILL.md § Codes & labels, § Familiar-domain trap) // --------------------------------------------------------------------------- export type NodeLevel = 'project' | 'application' | 'module' | 'section' | 'resource' /** The authorable levels — `project` is the root, created once with the first application. */ export const NODE_LEVELS = ['application', 'module', 'section', 'resource'] as const export type AuthorableLevel = (typeof NODE_LEVELS)[number] export const APP_MODULE_CODE_RE = /^[A-Z][A-Z0-9_]*$/ export const SECTION_RESOURCE_CODE_RE = /^[a-z][a-z0-9-]*$/ /** A label carrying one of these bundles two concepts (SKILL.md § Codes & labels). */ export const FORBIDDEN_LABEL_CHARS_RE = /[&/\\|<>"]/ export const MENU_BOUNDS = { applications: 4, modulesPerApp: 8, sectionsPerModule: 6, resourcesPerSection: 8 } as const /** `-detail`/`-edit`/… are actions, `-board`/`-workflow`/`-pipeline` are representations — never a section code (levels/sections.md). */ export const FORBIDDEN_SECTION_SUFFIX_RE = /-(detail|edit|create|form|board|workflow|pipeline)$/ export function codeFormatFor(level: AuthorableLevel): RegExp { return level === 'application' || level === 'module' ? APP_MODULE_CODE_RE : SECTION_RESOURCE_CODE_RE } // --------------------------------------------------------------------------- // Mechanical audit constants — SINGLE SOURCE for audit-ba rules/menu.ts + rules/sections.ts // --------------------------------------------------------------------------- /** MENU-008: the explicit empty form — absence of exclusions as an ASSERTION, not an omission. */ export const EMPTY_HP_MARKER = '_Aucune exclusion connue à ce stade._' /** MENU-007: below this, a REQUIRED Contexte (app/module) reads as a stub. */ export const CONTEXT_MIN_CHARS = 120 /** MENU-003: configuration is a MODULE, never an application (nor a section — prohibition 2). */ export const CONFIG_APP_RE = /^(settings|params|param[eè]tres|configuration|config)$/i /** MENU-003: cross-cutting technical features that must never be user modules/apps. */ export const TECHNICAL_FEATURES_RE = /^(auth|sso|authentification|notifications?|workflows?|data-?export|export de donn[eé]es|audit-?log|journal d'audit)$/i /** MENU-003: the sentence a Contexte must carry to extend a built-in platform app instead of duplicating it. */ export const EXTENSION_DECLARATION_RE = /extension de l'application plateforme|extends the built-in/i /** SEC-005: ambiguous section names (warn). */ export const AMBIGUOUS_SECTION_NAMES: ReadonlySet = new Set([ 'settings', 'options', 'configuration', 'config', 'parametres', 'utilities', 'tools', 'outils', 'misc', 'other', 'divers', 'autre', ]) /** XAPP-001: a module/section motif repeated in ≥ 2 applications (warn). */ export const XAPP_SECTION_PATTERNS: readonly string[] = ['rapports', 'reports', 'parametrage', 'settings', 'portail', 'portal'] /** Case/accent-insensitive fold of a node code (NFD strip + lowercase + trim). */ export function foldMenuCode(code: string): string { return normalizeEntityToken(code) } // --------------------------------------------------------------------------- // Code spellings // --------------------------------------------------------------------------- /** Long-code segment (`UPPER_UNDERSCORE`) → kebab folder name. */ export function sectionCodeToFolder(code: string): string { return code.toLowerCase().replace(/_/g, '-') } /** Kebab folder name (`exchange-history`) → long-code segment (`EXCHANGE_HISTORY`). */ export function sectionFolderToCode(folder: string): string { return folder.toUpperCase().replace(/-/g, '_') } /** Nav form (lower-kebab) — the spelling the core-seed spec, `previousCodes=` and permission paths carry. */ export function navFormOf(code: string): string { return code.toLowerCase().replace(/_/g, '-') } export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } // --------------------------------------------------------------------------- // Node anchor — `` // --------------------------------------------------------------------------- const ANCHOR_RE = // export interface NodeAnchor { kind: string level: string code: string /** `depends=APP/MOD,APP/MOD` (module anchors, written by /ba-create-ba-order). */ depends: string[] /** `previousCodes=a,b` — nav-form aliases of the codes this node carried before. */ previousCodes: string[] /** Every attribute verbatim, in file order — unknown ones survive a rewrite. */ attrs: Array<{ key: string; value: string }> /** The whole `` comment as found. */ raw: string } export function parseNodeAnchor(content: string | null): NodeAnchor | null { if (content === null) return null const m = ANCHOR_RE.exec(content) if (!m) return null const attrs: Array<{ key: string; value: string }> = [] for (const tok of m[1].trim().split(/\s+/).filter(Boolean)) { const eq = tok.indexOf('=') if (eq <= 0) continue attrs.push({ key: tok.slice(0, eq), value: tok.slice(eq + 1) }) } const get = (k: string): string | undefined => attrs.find((a) => a.key === k)?.value const list = (v: string | undefined): string[] => (v ? v.split(',').map((s) => s.trim()).filter(Boolean) : []) return { kind: get('kind') ?? 'node', level: get('level') ?? '', code: get('code') ?? '', depends: list(get('depends')), previousCodes: list(get('previousCodes')), attrs, raw: m[0], } } export interface AnchorPatch { /** New `code=` value. */ code?: string /** Alias to append to `previousCodes=` (nav form recommended; deduplicated case-insensitively). */ addPreviousCode?: string /** Replace the `depends=` list; `[]` removes the attribute. */ depends?: string[] } /** * Rewrite the node anchor in place, preserving attribute ORDER and every * attribute the patch does not name. No anchor → content returned untouched * (the caller decides whether that is a defect). */ export function updateNodeAnchor(content: string, patch: AnchorPatch): string { const anchor = parseNodeAnchor(content) if (!anchor) return content const attrs = anchor.attrs.map((a) => ({ ...a })) const set = (key: string, value: string | null): void => { const idx = attrs.findIndex((a) => a.key === key) if (value === null) { if (idx >= 0) attrs.splice(idx, 1) return } if (idx >= 0) attrs[idx]!.value = value else attrs.push({ key, value }) } if (patch.code !== undefined) set('code', patch.code) if (patch.depends !== undefined) set('depends', patch.depends.length > 0 ? patch.depends.join(',') : null) if (patch.addPreviousCode !== undefined) { const alias = patch.addPreviousCode const current = attrs.find((a) => a.key === 'code')?.value if (!current || current.toLowerCase() !== alias.toLowerCase()) { const prev = anchor.previousCodes if (!prev.some((p) => p.toLowerCase() === alias.toLowerCase())) set('previousCodes', [...prev, alias].join(',')) } } const rebuilt = `` return content.replace(anchor.raw, rebuilt) } /** * Append an alias to the `previousCodes=` attribute of a node's * `` anchor (created when absent, merged + deduped when * present). Codes are stored in nav form (lower-kebab, the folder-name space * the core-seed spec consumes). Idempotent; never touches the node's own * `code=`. Historical implementation of reconcile-menu — kept verbatim so * its tests and its trailing-space output stay byte-identical. */ export function addPreviousCodeToAnchor(content: string, previousCode: string): string { const anchorRe = // const match = anchorRe.exec(content) if (!match) return content const attrs = match[1] const codeMatch = attrs.match(/\bcode=([^\s]+)/) if (codeMatch && codeMatch[1].toLowerCase() === previousCode.toLowerCase()) return content const prevMatch = attrs.match(/\bpreviousCodes=([^\s]+)/) let newAttrs: string if (prevMatch) { const codes = prevMatch[1].split(',').filter(Boolean) if (codes.some((c) => c.toLowerCase() === previousCode.toLowerCase())) return content codes.push(previousCode) newAttrs = attrs.replace(prevMatch[0], `previousCodes=${codes.join(',')}`) } else { newAttrs = `${attrs.trimEnd()} previousCodes=${previousCode} ` } return content.replace(match[0], ``) } // --------------------------------------------------------------------------- // Downstream long codes — `(UC|SCR|BR|RBAC)-APP-MOD-SEC[-RES]-NNN` // --------------------------------------------------------------------------- export type CodeLevel = 'module' | 'section' | 'resource' /** The node a long code belongs to, every segment in LONG-CODE form (`UPPER_UNDERSCORE`). */ export interface CodeScope { app: string mod: string sec?: string res?: string } const KIND = '(UC|SCR|BR|RBAC)' const SEG = '[A-Z][A-Z0-9_]*' /** * Regex source of the code TAIL (after `KIND-`) that the scope selects, with the * renamed/removed segment isolated. Groups: the caller's `replace` callbacks * rely on the documented group layout per level. */ function tailSource(scope: CodeScope, level: CodeLevel | 'node'): string { const app = escapeRegex(scope.app) const mod = escapeRegex(scope.mod) if (level === 'module' || (level === 'node' && !scope.sec)) { // APP-MOD-[-]-NNN — group 1 = "-SEC[-RES]", group 2 = NNN return `${app}-${mod}((?:-${SEG}){1,2})-(\\d{3})` } const sec = escapeRegex(scope.sec!) if (level === 'section' || (level === 'node' && !scope.res)) { // APP-MOD-SEC[-]-NNN — group 1 = "[-RES]", group 2 = NNN return `${app}-${mod}-${sec}((?:-${SEG})?)-(\\d{3})` } const res = escapeRegex(scope.res!) // APP-MOD-SEC-RES-NNN — group 1 = "" (kept for a uniform layout), group 2 = NNN return `${app}-${mod}-${sec}-${res}()-(\\d{3})` } /** * Rename ONE segment of every long code of the scope: the module segment * (`level: 'module'` — every section and resource code of the module follows), * the section segment (`'section'` — the section's own codes AND the * 4-segment codes of its resources) or the resource segment. `toCode` is in * LONG-CODE form. Idempotent. */ export function renameCodeSegment(content: string, scope: CodeScope, level: CodeLevel, toCode: string): string { const re = new RegExp(`\\b${KIND}-${tailSource(scope, level)}\\b`, 'g') const app = scope.app return content.replace(re, (_m, kind: string, tail: string, num: string) => { if (level === 'module') return `${kind}-${app}-${toCode}${tail}-${num}` if (level === 'section') return `${kind}-${app}-${scope.mod}-${toCode}${tail}-${num}` return `${kind}-${app}-${scope.mod}-${scope.sec}-${toCode}-${num}` }) } /** Count the long codes of the scope present in a text (planning / verification). */ export function countScopeCodes(content: string, scope: CodeScope): number { const re = new RegExp(`\\b${KIND}-${tailSource(scope, 'node')}\\b`, 'g') return (content.match(re) ?? []).length } /** * Historical reconcile-menu signature — substitute every * `(KIND)-APP-MOD-FROM-NNN` (and the 4-segment codes under FROM) for the same * code carrying TO. Idempotent. */ export function renameCodesInText(content: string, app: string, mod: string, fromSec: string, toSec: string): string { return renameCodeSegment(content, { app, mod, sec: fromSec }, 'section', toSec) } /** * DELETE: drop every heading block of the scope AND scrub its body * references. `sec` omitted = the whole module (every section); `res` given = * one resource only. */ export function removeCodesFromText(content: string, app: string, mod: string, sec?: string, res?: string): string { let work = removeHeadingBlocks(content, app, mod, sec, res) work = scrubBodyReferences(work, app, mod, sec, res) return work } /** * Drop every block starting with `### {KIND}--NNN` and ending at the * next `### `, `## `, or EOF. */ export function removeHeadingBlocks(content: string, app: string, mod: string, sec?: string, res?: string): string { const scope: CodeScope = { app, mod, ...(sec ? { sec } : {}), ...(res ? { res } : {}) } const headingPattern = new RegExp(`^###\\s+${KIND}-${tailSource(scope, 'node')}\\b.*$`, 'm') let work = content while (true) { const match = headingPattern.exec(work) if (!match) break const start = match.index const after = start + match[0].length // Find the next ### or ## heading (or EOF) from `after`. const tailPattern = /^(?:###?\s)/m const tail = work.slice(after) const nextMatch = tailPattern.exec(tail) const end = nextMatch ? after + nextMatch.index : work.length work = work.slice(0, start) + work.slice(end) // Collapse triple+ blank lines that may result. work = work.replace(/\n{3,}/g, '\n\n') } return work } /** * Remove body-form references (in bullets, lists, table cells). Replaces an * empty list bullet ("- " or " -") with "- —". Removes a `linkedUseCases:` * field that becomes empty. */ export function scrubBodyReferences(content: string, app: string, mod: string, sec?: string, res?: string): string { const scope: CodeScope = { app, mod, ...(sec ? { sec } : {}), ...(res ? { res } : {}) } const codeSource = `\\b(?:UC|SCR|BR|RBAC)-${tailSource(scope, 'node').replace(/\((?!\?)/g, '(?:')}\\b` const codeRegex = new RegExp(codeSource, 'g') // 1. Drop a comma-separated list entry. "A, UC-…-001, B" → "A, B". let work = content.replace(new RegExp(`,\\s*${codeRegex.source}|${codeRegex.source}\\s*,\\s*`, 'g'), '') // 2. Replace standalone tokens with empty string. work = work.replace(codeRegex, '') // 3. Normalize empty bullets `- ` → `- —`. Horizontal whitespace ONLY: `\s` // would swallow the newline of a last line and push the `—` to the next. work = work.replace(/^([ \t]*-[ \t]*)$/gm, '$1—') // 4. Normalize `**Règles liées** :` left empty → `**Règles liées** : —`. work = work.replace(/^([ \t]*-[ \t]*\*\*[^*\n]+\*\*[ \t]*:[ \t]*)$/gm, '$1—') // 5. Normalize trailing `: ,` or `: ` from removed lists. work = work.replace(/:\s*,/g, ': —') // 6. Collapse table rows that become all-empty cells. // (Conservative: only collapse rows whose every cell is `|` plus whitespace.) work = work.replace(/^\|(\s*\|)+\s*$\n?/gm, '') return work } // --------------------------------------------------------------------------- // rbac.md — human permission paths `module.section[.resource].action` (nav form) // --------------------------------------------------------------------------- const MACHINE_BLOCKS: ReadonlyArray<[string, string]> = [ ['', ''], ['', ''], ] /** Split a rbac.md into segments — machine blocks are returned `locked` and never rewritten. */ function splitMachineBlocks(content: string): Array<{ text: string; locked: boolean }> { const out: Array<{ text: string; locked: boolean }> = [] let rest = content while (rest.length > 0) { let best: { begin: number; end: number } | null = null for (const [b, e] of MACHINE_BLOCKS) { const bi = rest.indexOf(b) if (bi < 0) continue const ei = rest.indexOf(e, bi) const end = ei < 0 ? rest.length : ei + e.length if (!best || bi < best.begin) best = { begin: bi, end } } if (!best) { out.push({ text: rest, locked: false }) break } if (best.begin > 0) out.push({ text: rest.slice(0, best.begin), locked: false }) out.push({ text: rest.slice(best.begin, best.end), locked: true }) rest = rest.slice(best.end) } return out } export interface PermissionScope { /** Module code in ANY spelling — normalised to nav form. */ module: string section?: string resource?: string } /** Regex source of the backticked path PREFIX the scope selects (nav form), with a trailing `.`. */ function permissionPrefixSource(scope: PermissionScope): string { const parts = [navFormOf(scope.module)] if (scope.section) parts.push(navFormOf(scope.section)) if (scope.resource) parts.push(navFormOf(scope.resource)) return parts.map(escapeRegex).join('\\.') } /** * Rename the module / section / resource segment of every backticked HUMAN * permission path (`` `pipeline.opportunites.read` ``). Machine blocks are * byte-identical in the output (their owners re-derive them). `toCode` in any * spelling — normalised to nav form. */ export function renamePermissionPaths(rbacContent: string, scope: PermissionScope, toCode: string): string { const prefix = permissionPrefixSource(scope) const re = new RegExp(`\`${prefix}(\\.[a-z0-9-]+(?:\\.[a-z0-9-]+)*)\``, 'g') const parts = [navFormOf(scope.module)] if (scope.section) parts.push(navFormOf(scope.section)) if (scope.resource) parts.push(navFormOf(scope.resource)) parts[parts.length - 1] = navFormOf(toCode) const newPrefix = parts.join('.') return splitMachineBlocks(rbacContent) .map((seg) => (seg.locked ? seg.text : seg.text.replace(re, (_m, tail: string) => `\`${newPrefix}${tail}\``))) .join('') } /** Count the human permission paths of the scope (planning). Machine blocks excluded. */ export function countPermissionPaths(rbacContent: string, scope: PermissionScope): number { const re = new RegExp(`\`${permissionPrefixSource(scope)}\\.[a-z0-9-]+(?:\\.[a-z0-9-]+)*\``, 'g') return splitMachineBlocks(rbacContent) .filter((seg) => !seg.locked) .reduce((n, seg) => n + (seg.text.match(re) ?? []).length, 0) } /** * DELETE: drop every HUMAN table row whose backticked permission path lives * under the scope. Machine blocks byte-identical. */ export function removePermissionPaths(rbacContent: string, scope: PermissionScope): string { const re = new RegExp(`\`${permissionPrefixSource(scope)}\\.[a-z0-9-]+(?:\\.[a-z0-9-]+)*\``) return splitMachineBlocks(rbacContent) .map((seg) => { if (seg.locked) return seg.text const eol = seg.text.includes('\r\n') ? '\r\n' : '\n' return seg.text .split(/\r?\n/) .filter((line) => !(/^\s*\|/.test(line) && re.test(line))) .join(eol) }) .join('') } // --------------------------------------------------------------------------- // Pagespecs — the fenced ```json machine block (create-prd/SKILL.md § pagespecs) // --------------------------------------------------------------------------- /** The FIRST fenced ```json … ``` block (same regex as create-prd's derive CLIs). */ export const PAGESPEC_JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ export interface PagespecRenameSpec { /** Application code in ANY spelling (matches `appCode` / `relatedApp` / `fkTo.app` in nav form). */ app: string /** Section rename: the module the section belongs to + old/new section codes (any spelling). */ mod?: string oldSec?: string newSec?: string /** Module rename: old/new module codes (any spelling). */ oldMod?: string newMod?: string /** * True when the pagespec belongs to the renamed node's OWN module — enables the * root-level `module` / `section` / `routeParent` and the `routes..` rewrites. * Cross-references (permissions, navRoute, fkTo, relatedModule/Section, apiEndpoint) * are rewritten in every pagespec of the tree regardless. */ ownModule: boolean } export interface PagespecRenameResult { content: string /** Number of string values rewritten. */ count: number /** `parse-error` when the block is not valid JSON (content returned untouched). */ error?: string } /** * Rewrite the pagespec references to a renamed node — EXACT bounded tokens * only, inside the json block only (re-serialised with the 2-space indent the * derive CLIs use). Anything outside the token list is left for * `residualTokenScan` to SAY. */ export function renamePagespecRefs(content: string, spec: PagespecRenameSpec): PagespecRenameResult { const m = PAGESPEC_JSON_BLOCK_RE.exec(content) if (!m) return { content, count: 0 } let block: unknown try { block = JSON.parse(m[1]) } catch { return { content, count: 0, error: 'parse-error' } } if (!block || typeof block !== 'object' || Array.isArray(block)) return { content, count: 0 } const app = navFormOf(spec.app) const root = block as Record const rootApp = typeof root.appCode === 'string' ? navFormOf(root.appCode) : undefined const sameApp = (candidate: unknown): boolean => { if (typeof candidate === 'string') return navFormOf(candidate) === app return rootApp === undefined || rootApp === app } let count = 0 const isSectionRename = !!(spec.mod && spec.oldSec && spec.newSec) const isModuleRename = !!(spec.oldMod && spec.newMod) const mod = spec.mod ? navFormOf(spec.mod) : undefined const oldSec = spec.oldSec ? navFormOf(spec.oldSec) : undefined const newSec = spec.newSec ? navFormOf(spec.newSec) : undefined const oldMod = spec.oldMod ? navFormOf(spec.oldMod) : undefined const newMod = spec.newMod ? navFormOf(spec.newMod) : undefined /** Rewrite a dotted path value (`permission`, `navRoute`, `createPermission`…). */ const rewriteDotted = (value: string): string => { if (isSectionRename) { const prefix = `${mod}.${oldSec}` if (value === prefix) return `${mod}.${newSec}` if (value.startsWith(`${prefix}.`)) return `${mod}.${newSec}${value.slice(prefix.length)}` } if (isModuleRename) { if (value === oldMod) return newMod! if (value.startsWith(`${oldMod}.`)) return `${newMod}${value.slice(oldMod!.length)}` } return value } /** Rewrite an `/api///…` endpoint. */ const rewriteEndpoint = (value: string): string => { if (isSectionRename) { const prefix = `/api/${mod}/${oldSec}` if (value === prefix || value.startsWith(`${prefix}/`)) return `/api/${mod}/${newSec}${value.slice(prefix.length)}` } if (isModuleRename) { const prefix = `/api/${oldMod}` if (value === prefix || value.startsWith(`${prefix}/`)) return `/api/${newMod}${value.slice(prefix.length)}` } return value } const setIf = (obj: Record, key: string, next: string): void => { if (obj[key] !== next) { obj[key] = next count += 1 } } const walk = (node: unknown, parentKey: string | null, depth: number): void => { if (Array.isArray(node)) { for (const item of node) walk(item, parentKey, depth + 1) return } if (!node || typeof node !== 'object') return const obj = node as Record const objApp = obj.relatedApp ?? obj.app // Root-level identity of the page — own module only. if (depth === 0 && spec.ownModule && sameApp(undefined)) { if (isModuleRename && typeof obj.module === 'string' && navFormOf(obj.module) === oldMod) setIf(obj, 'module', newMod!) if (isSectionRename && typeof obj.module === 'string' && navFormOf(obj.module) === mod) { if (typeof obj.section === 'string' && navFormOf(obj.section) === oldSec) setIf(obj, 'section', newSec!) if (typeof obj.routeParent === 'string' && navFormOf(obj.routeParent) === oldSec) setIf(obj, 'routeParent', newSec!) } } // relatedTabs[] entries. if (typeof obj.relatedModule === 'string' && sameApp(objApp)) { if (isModuleRename && navFormOf(obj.relatedModule) === oldMod) setIf(obj, 'relatedModule', newMod!) if (isSectionRename && navFormOf(obj.relatedModule) === mod && typeof obj.relatedSection === 'string' && navFormOf(obj.relatedSection) === oldSec) { setIf(obj, 'relatedSection', newSec!) } } // fkTo objects (filters / form fields). if (parentKey === 'fkTo' && typeof obj.module === 'string' && sameApp(objApp)) { if (isModuleRename && navFormOf(obj.module) === oldMod) setIf(obj, 'module', newMod!) } for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { if (key === 'apiEndpoint') { if (sameApp(objApp)) setIf(obj, key, rewriteEndpoint(value)) } else if (key === 'targetRoute') { if (isSectionRename && spec.ownModule && depth > 0 && value.startsWith(`routes.${oldSec}.`)) setIf(obj, key, `routes.${newSec}.${value.slice(`routes.${oldSec}.`.length)}`) } else if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) && sameApp(objApp)) { setIf(obj, key, rewriteDotted(value)) } continue } if (value && typeof value === 'object') walk(value, key, depth + 1) } } walk(root, null, 0) if (count === 0) return { content, count: 0 } const newJson = JSON.stringify(root, null, 2) const rewritten = content.slice(0, m.index) + '```json\n' + newJson + '\n```' + content.slice(m.index + m[0].length) return { content: rewritten, count } } /** * Whole-token occurrences of a node code (every spelling: kebab, UPPER_UNDERSCORE, * lower) on lines that carry a code-ish context (backticks, a markdown link * target, a path, a JSON string). Returns `line: excerpt` entries. This is what * keeps a bounded rewrite honest — whatever the token lists miss is reported. */ export function residualTokenScan(content: string, code: string, limit = 40): string[] { const forms = new Set([code, navFormOf(code), sectionFolderToCode(code), code.toLowerCase()]) const alternation = [...forms].map(escapeRegex).join('|') const tokenRe = new RegExp(`(?`) are // re-derived by their owner CLI, never hand-fixed — their leftovers are the // `reportOnly` item, not a residual. const blockBeginRe = // const blockEndRe = // const hits: string[] = [] const lines = content.split(/\r?\n/) let locked = false for (let i = 0; i < lines.length && hits.length < limit; i++) { const line = lines[i]! if (blockBeginRe.test(line)) { locked = true continue } if (blockEndRe.test(line)) { locked = false continue } if (locked || !contextRe.test(line)) continue if (tokenRe.test(line)) hits.push(`${i + 1}: ${line.trim().slice(0, 160)}`) } return hits } // --------------------------------------------------------------------------- // entité.md — relation scope `cross-module (APP/MOD)` of the OTHER modules // --------------------------------------------------------------------------- export function renameCrossModuleScopes(entiteContent: string, app: string, oldMod: string, newMod: string): { content: string; count: number } { const re = new RegExp(`(scope\\s+cross-module\\s*\\(\\s*)${escapeRegex(app)}\\/${escapeRegex(oldMod)}(\\s*\\))`, 'gi') let count = 0 const content = entiteContent.replace(re, (_m, pre: string, post: string) => { count += 1 return `${pre}${app}/${newMod}${post}` }) return { content, count } } export function countCrossModuleScopes(entiteContent: string, app: string, mod: string): number { const re = new RegExp(`scope\\s+cross-module\\s*\\(\\s*${escapeRegex(app)}\\/${escapeRegex(mod)}\\s*\\)`, 'gi') return (entiteContent.match(re) ?? []).length } // --------------------------------------------------------------------------- // `## Enfants` / `## Dépendances` — line-level splices (the rest of the file is byte-identical) // --------------------------------------------------------------------------- export type EnfantsChange = | { op: 'add'; code: string; label: string } | { op: 'rename'; code: string; newCode: string; label?: string } | { op: 'remove'; code: string } /** `- [CODE](./CODE/index.md) — Label` */ export function enfantsLine(code: string, label: string): string { return `- [${code}](./${code}/index.md) — ${label}` } function childLineRe(code: string): RegExp { const c = escapeRegex(code) return new RegExp(`^\\s*-\\s*\\[${c}\\]\\(\\./${c}/index\\.md\\)(?:\\s*[—-]\\s*(.*))?\\s*$`, 'i') } /** * Splice the `## Enfants` list of a parent `index.md`. The heading is created * at the end of the file when missing (add only). Every other line of the file * — the anchor with its `depends=`/`previousCodes=`, the Contexte, the * `**Sources**` lines, the Hors-périmètre — is untouched. */ export function spliceEnfants(parentContent: string, change: EnfantsChange): { content: string; changed: boolean } { const eol = parentContent.includes('\r\n') ? '\r\n' : '\n' const lines = parentContent.split(/\r?\n/) const headingIdx = lines.findIndex((l) => /^##\s+enfants\s*$/i.test(l)) if (headingIdx === -1) { if (change.op !== 'add') return { content: parentContent, changed: false } const trimmed = lines.slice() while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') trimmed.pop() const out = [...trimmed, '', '## Enfants', enfantsLine(change.code, change.label), ''] return { content: out.join(eol), changed: true } } let end = lines.length for (let i = headingIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i]!)) { end = i break } } const section = lines.slice(headingIdx + 1, end) const existingIdx = section.findIndex((l) => childLineRe(change.code).test(l)) if (change.op === 'add') { if (existingIdx >= 0) return { content: parentContent, changed: false } let insertAt = section.length while (insertAt > 0 && section[insertAt - 1]!.trim() === '') insertAt -= 1 const newSection = [...section.slice(0, insertAt), enfantsLine(change.code, change.label), ...section.slice(insertAt)] const out = [...lines.slice(0, headingIdx + 1), ...newSection, ...lines.slice(end)] return { content: out.join(eol), changed: true } } if (existingIdx < 0) return { content: parentContent, changed: false } const newSection = section.slice() if (change.op === 'remove') { newSection.splice(existingIdx, 1) } else { const m = childLineRe(change.code).exec(section[existingIdx]!) const label = change.label ?? m?.[1]?.trim() ?? change.newCode newSection[existingIdx] = enfantsLine(change.newCode, label) } const out = [...lines.slice(0, headingIdx + 1), ...newSection, ...lines.slice(end)] return { content: out.join(eol), changed: true } } /** * `## Dépendances` of a module index.md: `- [MOD](../MOD/index.md) — why`. * Rename the link (module rename) or drop the line (module delete). */ export function spliceDependances(content: string, change: { op: 'rename'; code: string; newCode: string } | { op: 'remove'; code: string }): { content: string; changed: boolean } { const eol = content.includes('\r\n') ? '\r\n' : '\n' const lines = content.split(/\r?\n/) const c = escapeRegex(change.code) const re = new RegExp(`^(\\s*-\\s*)\\[${c}\\]\\(\\.\\./${c}/index\\.md\\)(.*)$`) let changed = false const out: string[] = [] for (const line of lines) { const m = re.exec(line) if (!m) { out.push(line) continue } changed = true if (change.op === 'remove') continue out.push(`${m[1]}[${change.newCode}](../${change.newCode}/index.md)${m[2]}`) } return { content: changed ? out.join(eol) : content, changed } } // --------------------------------------------------------------------------- // The tree — folders carrying an index.md (same exclusions as audit-ba's corpus) // --------------------------------------------------------------------------- const SKIP_DIRS: ReadonlySet = new Set(['pagespecs', 'node_modules']) /** Direct child node folders: `_*`, `.*`, `pagespecs`, `node_modules` excluded, sorted. */ export function listNodeDirs(dir: string): string[] { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] } return entries .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) .map((e) => e.name) .sort() } function readDocOrNull(path: string): string | null { try { if (existsSync(path) && statSync(path).isFile()) return readFileSync(path, 'utf8') } catch { /* unreadable — treated as absent */ } return null } /** Both NFC and NFD spellings of an accented filename exist on real disks. */ export function readAccentedDoc(dir: string, nfcName: string): string | null { return readDocOrNull(join(dir, nfcName)) ?? readDocOrNull(join(dir, nfcName.normalize('NFD'))) } export interface MenuNode { level: AuthorableLevel /** Folder name = code (UPPERCASE app/module, kebab section/resource). */ code: string /** Codes from the application down to this node. */ path: string[] dir: string indexRaw: string | null /** Label after the em-dash of the `# CODE — Label` title (falls back to the title, then the code). */ label: string anchor: NodeAnchor | null children: MenuNode[] } export interface MenuTree { root: string rootIndexRaw: string | null apps: MenuNode[] /** Every node, depth-first. */ all: MenuNode[] } const LEVEL_ORDER: AuthorableLevel[] = ['application', 'module', 'section', 'resource'] export function labelOfIndex(indexRaw: string | null, code: string): string { if (indexRaw === null) return code const title = indexRaw.match(/^#\s+(.+?)\s*$/m)?.[1] if (!title) return code const dash = title.match(/^(.+?)\s+[—–-]\s+(.+)$/) return dash ? dash[2]!.trim() : title.trim() } export function loadMenuTree(baRoot: string): MenuTree { const all: MenuNode[] = [] const load = (dir: string, level: number, path: string[]): MenuNode[] => { if (level >= LEVEL_ORDER.length) return [] return listNodeDirs(dir).map((code) => { const nodeDir = join(dir, code) const indexRaw = readDocOrNull(join(nodeDir, 'index.md')) const node: MenuNode = { level: LEVEL_ORDER[level]!, code, path: [...path, code], dir: nodeDir, indexRaw, label: labelOfIndex(indexRaw, code), anchor: parseNodeAnchor(indexRaw), children: [], } all.push(node) node.children = load(nodeDir, level + 1, node.path) return node }) } const apps = existsSync(baRoot) ? load(baRoot, 0, []) : [] return { root: baRoot, rootIndexRaw: readDocOrNull(join(baRoot, 'index.md')), apps, all } } /** Find a node by its code path (case-insensitive on every segment). */ export function findNode(tree: MenuTree, path: string[]): MenuNode | undefined { let level: MenuNode[] = tree.apps let found: MenuNode | undefined for (const seg of path) { found = level.find((n) => n.code.toLowerCase() === seg.toLowerCase()) if (!found) return undefined level = found.children } return found } // --------------------------------------------------------------------------- // Node documents — the six placeholders + the canonical index.md // --------------------------------------------------------------------------- export interface PlaceholderDoc { file: string content: string } /** The six concept docs every node carries (French, accented filenames — verbatim, NFC). */ export const PLACEHOLDER_DOCS: ReadonlyArray<{ file: string; kind: string; phase: string; skill: string }> = [ { file: 'acteur.md', kind: 'acteur', phase: 'acteurs', skill: '/ba-create-actors' }, { file: 'entité.md', kind: 'entité', phase: 'modèle de données', skill: '/ba-create-data-model' }, { file: 'use-case.md', kind: 'use-case', phase: "cas d'usage", skill: '/ba-create-use-case' }, { file: 'règles-métier.md', kind: 'rules', phase: 'règles métier', skill: '/ba-create-business-rules' }, { file: 'rbac.md', kind: 'rbac', phase: 'RBAC', skill: '/ba-create-rbac' }, { file: 'screen.md', kind: 'screen', phase: 'écrans', skill: '/ba-create-screen' }, ] export function placeholderDocs(level: AuthorableLevel, code: string): PlaceholderDoc[] { return PLACEHOLDER_DOCS.map((d) => ({ file: d.file, content: `\n_À définir lors de la phase « ${d.phase} » (${d.skill})._\n`, })) } export interface IndexMdInput { level: NodeLevel code: string label: string contexte?: string /** `[]` → the explicit empty marker; `undefined` → no `## Hors-périmètre` section (sections/resources without exclusions). */ horsPerimetre?: string[] /** `SRC-NNN §n` citations closing the Contexte. */ sources?: string[] /** Module anchors only — `depends=APP/MOD,…`. */ depends?: string[] } /** The canonical `index.md` of a NEW node (`_workflow/doc-templates.md` § index.md) — no children yet. */ export function renderIndexMd(input: IndexMdInput): string { const attrs = [`kind=node`, `level=${input.level}`, `code=${input.code}`] if (input.depends && input.depends.length > 0) attrs.push(`depends=${input.depends.join(',')}`) const out: string[] = [``, `# ${input.code} — ${input.label}`, ''] out.push('## Contexte') if (input.contexte && input.contexte.trim() !== '') out.push(input.contexte.trim()) if (input.sources && input.sources.length > 0) out.push(`- **Sources** : ${input.sources.join(', ')}`) out.push('') if (input.horsPerimetre !== undefined) { out.push('## Hors-périmètre') const bullets = input.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('') } out.push('## Enfants', '') return out.join('\n') }