/** * menu-node — add / rename / delete on a synthetic tree (fixture.ts). * * The invariants each block pins: * - `check` writes NOTHING; `write` applies exactly the plan; * - add: the parent's anchor (`depends=`), Contexte and `**Sources**` survive * byte-for-byte, the six placeholders exist, the audit vocabulary blocks or * warns exactly as audit-ba would; * - rename: every long code (3- and 4-segment), the human permission paths, * `depends=` / `## Dépendances` / `cross-module` scopes, the pagespec tokens, * the folder, `previousCodes=` — and the residual scan SAYS what is left; * then reconcile-menu's own scan finds no ghost; * - delete: blocks + body references + rows + folder + `## Enfants`, pagespecs * REPORTED never deleted, reconcile-menu finds no ghost afterwards. */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildPlan } from '../../../../reconcile-menu/cli/reconcile-menu/detect.js' import { scan } from '../../../../reconcile-menu/cli/reconcile-menu/scan.js' import { executeMenuNode } from '../execute.js' import type { MenuNodeInput, MenuNodeReport } from '../types.js' import { validateSpec } from '../validate.js' import { writeMenuFixture } from './fixture.js' let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'menu-node-')) writeMenuFixture(root) }) afterEach(() => { rmSync(root, { recursive: true, force: true }) }) const read = (rel: string): string => readFileSync(join(root, rel), 'utf8') const exists = (rel: string): boolean => existsSync(join(root, rel)) function run(partial: Partial & { op: MenuNodeInput['op']; level: MenuNodeInput['level']; code: string }): MenuNodeReport { const v = validateSpec({ baRoot: root, mode: 'check', ...partial }) if (!v.valid || !v.spec || !v.scope) throw new Error(`invalid spec: ${v.errors.join(' | ')}`) return executeMenuNode(v.spec, v.scope) } async function reconcilePlan(): Promise<{ renames: number; deletions: number }> { const plan = buildPlan(await scan(root)) return { renames: plan.renames.length, deletions: plan.deletions.length } } const SECTION_CONTEXTE = 'Les devis émis pour un client, de la rédaction à la signature ; sert les commerciaux et leur manager, s\'arrête à la facturation.' // --------------------------------------------------------------------------- // validate // --------------------------------------------------------------------------- describe('validateSpec', () => { it('usage errors: label / to / contexte+horsPerimetre / parent shape', () => { expect(validateSpec({ baRoot: root, op: 'add', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'x' }).errors.join(' ')).toMatch(/label/) expect(validateSpec({ baRoot: root, op: 'rename', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }).errors.join(' ')).toMatch(/`to`/) const mod = validateSpec({ baRoot: root, op: 'add', level: 'module', parent: { app: 'CRM' }, code: 'STOCK', label: 'Stock' }) expect(mod.errors.join(' ')).toMatch(/contexte/) expect(mod.errors.join(' ')).toMatch(/horsPerimetre/) expect(validateSpec({ baRoot: root, op: 'add', level: 'section', parent: { app: 'CRM' }, code: 'x', label: 'X' }).errors.join(' ')).toMatch(/parent\.module/) expect(validateSpec({ baRoot: root, op: 'delete', level: 'module', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'X' }).errors.join(' ')).toMatch(/does not take parent\.module/) expect(validateSpec({ baRoot: root, op: 'delete', level: 'section', parent: { app: 'CRM', module: 'GHOST' }, code: 'x' }).errors.join(' ')).toMatch(/Module folder not found/) expect(validateSpec({ baRoot: join(root, 'nowhere'), op: 'add', level: 'module', parent: { app: 'CRM' }, code: 'X', label: 'X', contexte: 'c', horsPerimetre: [] }).errors.join(' ')).toMatch(/BA root directory not found/) }) it('resolves the parent case-insensitively and accepts a fresh tree for the first application', () => { const v = validateSpec({ baRoot: root, op: 'add', level: 'resource', parent: { app: 'crm', module: 'pipeline', section: 'Clients' }, code: 'gold', label: 'Gold' }) expect(v.valid).toBe(true) expect(v.scope?.parentPath).toEqual(['CRM', 'PIPELINE', 'clients']) const fresh = validateSpec({ baRoot: join(root, 'fresh'), op: 'add', level: 'application', code: 'CRM', label: 'CRM', contexte: 'c', horsPerimetre: [] }) expect(fresh.valid).toBe(true) expect(fresh.scope?.freshTree).toBe(true) }) }) // --------------------------------------------------------------------------- // add // --------------------------------------------------------------------------- describe('add', () => { const addDevisSpec = { op: 'add' as const, level: 'section' as const, parent: { app: 'CRM', module: 'PIPELINE' }, code: 'contrats', label: 'Contrats', contexte: SECTION_CONTEXTE, sources: ['SRC-001 §3'] } it('check plans the node files + the parent, writes nothing', () => { const before = read('CRM/PIPELINE/index.md') const r = run(addDevisSpec) expect(r.blocked).toEqual([]) expect(r.noop).toBe(false) expect(r.plan.filesToCreate).toEqual(['CRM/PIPELINE/contrats/acteur.md', 'CRM/PIPELINE/contrats/entité.md', 'CRM/PIPELINE/contrats/index.md', 'CRM/PIPELINE/contrats/rbac.md', 'CRM/PIPELINE/contrats/règles-métier.md', 'CRM/PIPELINE/contrats/screen.md', 'CRM/PIPELINE/contrats/use-case.md']) expect(r.plan.filesToModify).toEqual(['CRM/PIPELINE/index.md']) expect(r.applied).toBeNull() expect(exists('CRM/PIPELINE/contrats')).toBe(false) expect(read('CRM/PIPELINE/index.md')).toBe(before) expect(r.nextSteps.join('\n')).toMatch(/derive-permission-floor/) // rbac.md of PIPELINE is authored }) it('write creates the node and splices the parent — anchor depends=, Contexte, Sources byte-identical', () => { const before = read('CRM/PIPELINE/index.md') const r = run({ ...addDevisSpec, mode: 'write' }) expect(r.applied?.filesCreated).toHaveLength(7) const index = read('CRM/PIPELINE/contrats/index.md') expect(index).toContain('') expect(index).toContain('# contrats — Contrats') expect(index).toContain('- **Sources** : SRC-001 §3') expect(index).not.toContain('## Hors-périmètre') // optional on a section, not given expect(read('CRM/PIPELINE/contrats/règles-métier.md')).toContain('') const after = read('CRM/PIPELINE/index.md') expect(after.split('\n')[0]).toBe('') expect(after).toContain('- [contrats](./contrats/index.md) — Contrats') expect(after.replace('- [contrats](./contrats/index.md) — Contrats\n', '')).toBe(before) // idempotent const again = run({ ...addDevisSpec, mode: 'write' }) expect(again.noop).toBe(true) expect(again.applied).toBeNull() }) it('module: Hors-périmètre [] writes the explicit marker; a short Contexte only warns', () => { const r = run({ op: 'add', level: 'module', parent: { app: 'CRM' }, code: 'STOCK', label: 'Stock', contexte: 'Gestion des stocks.', horsPerimetre: [], mode: 'write' }) expect(r.blocked).toEqual([]) expect(r.warnings.join('\n')).toMatch(/MENU-007/) expect(read('CRM/STOCK/index.md')).toContain('## Hors-périmètre\n_Aucune exclusion connue à ce stade._') expect(read('CRM/index.md')).toContain('- [STOCK](./STOCK/index.md) — Stock') }) it('blocked: format, settings section, action suffix, hierarchy collision, duplicate sibling, platform app, technical feature', () => { const sec = (code: string, extra: Partial = {}): string[] => run({ op: 'add', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code, label: 'X', contexte: SECTION_CONTEXTE, ...extra }).blocked.map((b) => b.code) expect(sec('Clients')).toContain('code-format') expect(sec('settings')).toContain('settings-section') expect(sec('clients-board')).toContain('action-or-representation-suffix') expect(sec('pipeline')).toContain('hierarchy-code-collision') expect(sec('a-b', { label: 'Devis & Contrats' })).toContain('label-forbidden-chars') mkdirSync(join(root, 'CRM', 'PIPELINE', 'Contrats')) expect(sec('contrats')).toContain('duplicate-sibling') const app = (code: string, extra: Partial = {}): MenuNodeReport => run({ op: 'add', level: 'application', code, label: code, contexte: SECTION_CONTEXTE, horsPerimetre: [], ...extra }) expect(app('RH').blocked.map((b) => b.code)).toContain('platform-app-collision') expect(app('RH', { extension: true, contexte: "Extension de l'application plateforme `hr` (ApplicationId x) — modules delta uniquement, jamais une seconde app RH, trois phrases pour la borne." }).blocked).toEqual([]) expect(app('MYSPACE').blocked.map((b) => b.code)).toContain('platform-app-not-extendable') expect(app('SETTINGS').blocked.map((b) => b.code)).toContain('config-is-a-module') expect(app('NOTIFICATIONS').blocked.map((b) => b.code)).toContain('technical-feature') expect(run({ op: 'add', level: 'module', parent: { app: 'CRM' }, code: 'ABSENCES', label: 'Absences', contexte: SECTION_CONTEXTE, horsPerimetre: [] }).blocked.map((b) => b.code)).toContain('platform-module-collision') }) it('blocked: bound exceeded (applications ≤ 4)', () => { for (const a of ['A1', 'A2']) { const r = run({ op: 'add', level: 'application', code: a, label: a, contexte: SECTION_CONTEXTE, horsPerimetre: [], mode: 'write' }) expect(r.blocked).toEqual([]) } expect(run({ op: 'add', level: 'application', code: 'A3', label: 'A3', contexte: SECTION_CONTEXTE, horsPerimetre: [] }).blocked.map((b) => b.code)).toContain('bound-exceeded') }) it('warnings: SEC-005 ambiguous name, XAPP-001 motif already in another application', () => { expect(run({ op: 'add', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'divers', label: 'Divers', contexte: SECTION_CONTEXTE }).warnings.join('\n')).toMatch(/SEC-005/) expect(run({ op: 'add', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'rapports', label: 'Rapports', contexte: SECTION_CONTEXTE }).warnings.join('\n')).toMatch(/XAPP-001.*ERP\/REFERENCES\/rapports/) expect(run({ op: 'add', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'notes', label: 'Notes' }).warnings.join('\n')).toMatch(/MENU-007 : Contexte absent/) }) it('first application on a fresh tree creates the root index.md', () => { const fresh = join(root, 'fresh') const v = validateSpec({ baRoot: fresh, op: 'add', mode: 'write', level: 'application', code: 'CRM', label: 'CRM', contexte: SECTION_CONTEXTE, horsPerimetre: ['Pas de paie.'], project: { label: 'Demo', horsPerimetre: [] } }) const r = executeMenuNode(v.spec!, v.scope!) expect(r.blocked).toEqual([]) expect(readFileSync(join(fresh, 'index.md'), 'utf8')).toBe('\n# Demo\n\n## Contexte\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n\n## Enfants\n- [CRM](./CRM/index.md) — CRM\n') expect(readFileSync(join(fresh, 'CRM', 'index.md'), 'utf8')).toContain('## Hors-périmètre\n- Pas de paie.\n') }) }) // --------------------------------------------------------------------------- // rename // --------------------------------------------------------------------------- describe('rename', () => { it('section clients → tiers: check plans everything and writes nothing', () => { const r = run({ op: 'rename', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients', to: { code: 'tiers', label: 'Tiers' } }) expect(r.blocked).toEqual([]) expect(r.plan.codeRewrites.map((c) => c.file).sort()).toEqual(['CRM/PIPELINE/clients/screen.md', 'CRM/PIPELINE/clients/use-case.md', 'CRM/PIPELINE/clients/vip/use-case.md', 'CRM/PIPELINE/devis/use-case.md', 'CRM/PIPELINE/pagespecs/Client.list.md']) expect(r.plan.permissionPathRewrites).toEqual([{ file: 'CRM/PIPELINE/rbac.md', count: 2 }]) expect(r.plan.pagespecRewrites.map((c) => c.file).sort()).toEqual(['CRM/BILLING/pagespecs/Invoice.list.md', 'CRM/PIPELINE/pagespecs/Client.list.md', 'CRM/PIPELINE/pagespecs/Devis.list.md']) expect(r.plan.directoriesToMove).toEqual([{ from: 'CRM/PIPELINE/clients', to: 'CRM/PIPELINE/tiers' }]) expect(r.plan.residual.some((x) => x.startsWith('CRM/PIPELINE/use-case.md:'))).toBe(true) // the rollup pointer expect(r.plan.reportOnly.map((x) => x.what).join('\n')).toMatch(/rbac-floor/) expect(r.plan.reportOnly.map((x) => x.where)).toContain('CRM/PIPELINE/.run-snapshot.json') expect(r.applied).toBeNull() expect(exists('CRM/PIPELINE/clients')).toBe(true) expect(exists('CRM/PIPELINE/tiers')).toBe(false) }) it('section clients → tiers: write — codes, rows, pagespecs, folder, anchor, parent; reconcile finds no ghost; idempotent', async () => { const r = run({ op: 'rename', mode: 'write', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients', to: { code: 'tiers', label: 'Tiers' } }) expect(r.blocked).toEqual([]) expect(r.applied?.directoriesMoved).toEqual([{ from: 'CRM/PIPELINE/clients', to: 'CRM/PIPELINE/tiers' }]) expect(exists('CRM/PIPELINE/clients')).toBe(false) const uc = read('CRM/PIPELINE/tiers/use-case.md') expect(uc).toContain('### UC-CRM-PIPELINE-TIERS-001 — Créer un client') expect(uc).toContain('SCR-CRM-PIPELINE-TIERS-001') expect(read('CRM/PIPELINE/tiers/vip/use-case.md')).toContain('UC-CRM-PIPELINE-TIERS-VIP-001') expect(read('CRM/PIPELINE/devis/use-case.md')).toContain('UC-CRM-PIPELINE-TIERS-001, UC-CRM-PIPELINE-TIERS-002') const rbac = read('CRM/PIPELINE/rbac.md') expect(rbac).toContain('`pipeline.tiers.read`') expect(rbac).toContain('`pipeline.tiers.vip.export`') expect(rbac).toContain('| `crm.pipeline.clients.read` | floor |') const index = read('CRM/PIPELINE/tiers/index.md') expect(index.split('\n')[0]).toBe('') expect(index.split('\n')[1]).toBe('# tiers — Tiers') expect(read('CRM/PIPELINE/index.md')).toContain('- [tiers](./tiers/index.md) — Tiers') expect(read('CRM/PIPELINE/index.md')).not.toContain('[clients]') const client = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(read('CRM/PIPELINE/pagespecs/Client.list.md'))![1]!) expect(client.section).toBe('tiers') expect(client.permission).toBe('pipeline.tiers.read') expect(client.screenCode).toBe('SCR-CRM-PIPELINE-TIERS-001') expect(client.linkedUseCases).toEqual(['UC-CRM-PIPELINE-TIERS-001']) expect(client.actions[0].targetRoute).toBe('routes.tiers.detail(item.id)') const invoice = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(read('CRM/BILLING/pagespecs/Invoice.list.md'))![1]!) expect(invoice.filters[0].fkTo.navRoute).toBe('pipeline.tiers') expect(invoice.filters[0].fkTo.apiEndpoint).toBe('/api/pipeline/tiers/lookup') expect(invoice.relatedTabs[0].relatedSection).toBe('tiers') expect(await reconcilePlan()).toEqual({ renames: 0, deletions: 0 }) const again = run({ op: 'rename', mode: 'write', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients', to: { code: 'tiers' } }) expect(again.noop).toBe(true) }) it('module PIPELINE → SALES: depends=, ## Dépendances, cross-module scopes, pagespecs, rbac module segment, folder, alias', async () => { const r = run({ op: 'rename', mode: 'write', level: 'module', parent: { app: 'CRM' }, code: 'PIPELINE', to: { code: 'SALES' } }) expect(r.blocked).toEqual([]) expect(exists('CRM/SALES')).toBe(true) expect(exists('CRM/PIPELINE')).toBe(false) const billing = read('CRM/BILLING/index.md') expect(billing.split('\n')[0]).toBe('') expect(billing).toContain('- [SALES](../SALES/index.md) — factures liées aux clients') expect(read('CRM/BILLING/entité.md')).toContain('scope cross-module (CRM/SALES)') expect(r.plan.crossModuleScopeRewrites).toEqual([{ file: 'CRM/BILLING/entité.md', count: 1 }]) expect(read('CRM/SALES/clients/use-case.md')).toContain('UC-CRM-SALES-CLIENTS-001') expect(read('CRM/SALES/clients/vip/use-case.md')).toContain('UC-CRM-SALES-CLIENTS-VIP-001') const rbac = read('CRM/SALES/rbac.md') expect(rbac).toContain('`sales.clients.read`') expect(rbac).toContain('`sales.devis.approve`') expect(rbac).toContain('`crm.pipeline.clients.read` | floor') // machine block, re-derived by its owner const index = read('CRM/SALES/index.md') expect(index.split('\n')[0]).toBe('') expect(index.split('\n')[1]).toBe('# SALES — Pipeline') expect(read('CRM/index.md')).toContain('- [SALES](./SALES/index.md) — Pipeline') const client = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(read('CRM/SALES/pagespecs/Client.list.md'))![1]!) expect(client.module).toBe('sales') expect(client.permission).toBe('sales.clients.read') const invoice = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(read('CRM/BILLING/pagespecs/Invoice.list.md'))![1]!) expect(invoice.module).toBe('billing') expect(invoice.filters[0].fkTo.module).toBe('sales') expect(invoice.filters[0].fkTo.navRoute).toBe('sales.clients') expect(invoice.relatedTabs[0].relatedModule).toBe('sales') expect(await reconcilePlan()).toEqual({ renames: 0, deletions: 0 }) expect(run({ op: 'rename', mode: 'write', level: 'module', parent: { app: 'CRM' }, code: 'PIPELINE', to: { code: 'SALES' } }).noop).toBe(true) }) it('resource vip → premium: 4-segment codes and the resource permission path', () => { const r = run({ op: 'rename', mode: 'write', level: 'resource', parent: { app: 'CRM', module: 'PIPELINE', section: 'clients' }, code: 'vip', to: { code: 'premium' } }) expect(r.blocked).toEqual([]) expect(read('CRM/PIPELINE/clients/premium/use-case.md')).toContain('UC-CRM-PIPELINE-CLIENTS-PREMIUM-001') expect(read('CRM/PIPELINE/rbac.md')).toContain('`pipeline.clients.premium.export`') expect(read('CRM/PIPELINE/clients/index.md')).toContain('- [premium](./premium/index.md) — VIP') }) it('blocked: application, unknown node, target exists, bad target code', () => { expect(run({ op: 'rename', level: 'application', code: 'CRM', to: { code: 'SALES' } }).blocked[0]?.code).toBe('app-rename-unsupported') const missing = run({ op: 'rename', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'ghost', to: { code: 'x' } }) expect(missing.blocked[0]?.code).toBe('node-not-found') expect(missing.blocked[0]?.candidates).toEqual(['clients', 'devis']) expect(run({ op: 'rename', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients', to: { code: 'devis' } }).blocked.map((b) => b.code)).toContain('target-exists') expect(run({ op: 'rename', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients', to: { code: 'Tiers' } }).blocked.map((b) => b.code)).toContain('code-format') expect(exists('CRM/PIPELINE/clients')).toBe(true) }) }) // --------------------------------------------------------------------------- // delete // --------------------------------------------------------------------------- describe('delete', () => { it('section clients: check counts the downstream codes and reports the pagespec; nothing written', () => { const r = run({ op: 'delete', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }) expect(r.blocked).toEqual([]) expect(r.plan.downstreamCodes).toEqual([{ file: 'CRM/PIPELINE/devis/use-case.md', headings: 0, bodyRefs: 2 }]) expect(r.plan.permissionPathRewrites).toEqual([{ file: 'CRM/PIPELINE/rbac.md', count: 2 }]) expect(r.plan.directoriesToRemove).toEqual(['CRM/PIPELINE/clients']) expect(r.plan.reportOnly.map((x) => x.where)).toContain('CRM/PIPELINE/pagespecs/Client.list.md') expect(r.warnings.join('\n')).toMatch(/1 sous-nœud\(s\).*vip/) expect(r.applied).toBeNull() expect(exists('CRM/PIPELINE/clients')).toBe(true) }) it('section clients: write — blocks/body/rows/folder/Enfants; pagespec kept; reconcile finds no ghost', async () => { const r = run({ op: 'delete', mode: 'write', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }) expect(r.applied?.directoriesRemoved).toEqual(['CRM/PIPELINE/clients']) expect(exists('CRM/PIPELINE/clients')).toBe(false) expect(read('CRM/PIPELINE/devis/use-case.md')).toContain('- **Cas d\'usage liés** : —') expect(read('CRM/PIPELINE/devis/use-case.md')).not.toContain('CLIENTS') const rbac = read('CRM/PIPELINE/rbac.md') expect(rbac).not.toContain('`pipeline.clients.read`') expect(rbac).toContain('`pipeline.devis.approve`') expect(rbac).toContain('| `crm.pipeline.clients.read` | floor |') expect(read('CRM/PIPELINE/index.md')).not.toContain('[clients]') expect(read('CRM/PIPELINE/index.md')).toContain('- [devis](./devis/index.md) — Devis') expect(exists('CRM/PIPELINE/pagespecs/Client.list.md')).toBe(true) expect(await reconcilePlan()).toEqual({ renames: 0, deletions: 0 }) expect(run({ op: 'delete', mode: 'write', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }).noop).toBe(true) }) it('last section warns that the module becomes empty', () => { const r = run({ op: 'delete', level: 'section', parent: { app: 'CRM', module: 'BILLING' }, code: 'invoices' }) expect(r.warnings.join('\n')).toMatch(/Dernière section.*MENU-004/) }) it('module PIPELINE: depends= and ## Dépendances of BILLING cleaned, cross-module relation reported, folder gone', async () => { const r = run({ op: 'delete', mode: 'write', level: 'module', parent: { app: 'CRM' }, code: 'PIPELINE' }) expect(r.blocked).toEqual([]) expect(exists('CRM/PIPELINE')).toBe(false) const billing = read('CRM/BILLING/index.md') expect(billing.split('\n')[0]).toBe('') expect(billing).not.toContain('[PIPELINE]') expect(r.plan.dependsRewrites).toEqual([{ file: 'CRM/BILLING/index.md', count: 2 }]) expect(r.plan.reportOnly.map((x) => x.what).join('\n')).toMatch(/cross-module \(CRM\/PIPELINE\)/) expect(read('CRM/BILLING/entité.md')).toContain('scope cross-module (CRM/PIPELINE)') // reported, never rewritten expect(read('CRM/index.md')).not.toContain('[PIPELINE]') expect(r.warnings.join('\n')).toMatch(/2 pagespec\(s\)/) expect(await reconcilePlan()).toEqual({ renames: 0, deletions: 0 }) }) it('blocked: application; missing node with stranded codes; missing node without codes = noop', () => { expect(run({ op: 'delete', level: 'application', code: 'CRM' }).blocked[0]?.code).toBe('app-delete-unsupported') rmSync(join(root, 'CRM', 'PIPELINE', 'clients'), { recursive: true, force: true }) const stranded = run({ op: 'delete', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }) expect(stranded.blocked[0]?.code).toBe('node-not-found') expect(stranded.blocked[0]?.route).toMatch(/reconcile-menu/) writeFileSync(join(root, 'CRM', 'PIPELINE', 'devis', 'use-case.md'), '\n# x\n', 'utf8') expect(run({ op: 'delete', level: 'section', parent: { app: 'CRM', module: 'PIPELINE' }, code: 'clients' }).noop).toBe(true) }) })