/** * cli:menu-node — validate.ts * * Stage 1: Zod (v4, strict). Stage 2: the spec SHAPE per op/level (a label * for add, `to` for rename, a Contexte + Hors-périmètre for an application * or a module — the skill MUST author them, a missing one is a usage error). * Stage 3: the PARENT must resolve on disk (case-insensitive folder lookup, * lib/ba-rbac-rows resolveBaModuleDir for app + module). The BA root may be * absent for ONE case only: the first `add level=application`, which creates * the tree. * * Everything about the CONTENT of the tree (duplicate sibling, platform * collision, bounds, target exists…) is a `blocked[]` entry of the report — * never an exit. */ import { existsSync, readdirSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { resolveBaModuleDir } from '../../../../lib/ba-rbac-rows.js' import { MenuNodeInputSchema, type MenuNodeInput, type ValidationResult } from './types.js' export function findDirCaseInsensitive(parent: string, name: string): string | null { try { const lower = name.toLowerCase() const hit = readdirSync(parent, { withFileTypes: true }).find((e) => e.isDirectory() && e.name.toLowerCase() === lower) return hit ? hit.name : null } catch { return null } } const PARENT_KEYS_BY_LEVEL: Record> = { application: [], module: ['app'], section: ['app', 'module'], resource: ['app', 'module', 'section'], } export function validateSpec(raw: unknown, workdir?: string): ValidationResult { const parsed = MenuNodeInputSchema.safeParse(raw) if (!parsed.success) { return { valid: false, errors: parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`) } } const spec = parsed.data const errors: string[] = [] // --- op / level shape ----------------------------------------------------- if (spec.op === 'add') { if (!spec.label) errors.push('op=add requires `label` (the user-language label of the node).') if (spec.level === 'application' || spec.level === 'module') { if (!spec.contexte || spec.contexte.trim() === '') { errors.push(`op=add level=${spec.level} requires a non-empty \`contexte\` (3-5 sentences WHO / WHAT / LIMITS — MENU-007 errs on an empty one).`) } if (spec.horsPerimetre === undefined) { errors.push(`op=add level=${spec.level} requires \`horsPerimetre\` (bullets, or [] to write the explicit empty marker — MENU-008 errs when the section is missing).`) } } } if (spec.op === 'rename' && !spec.to) errors.push('op=rename requires `to` ({ code, label? }).') if (spec.op !== 'rename' && spec.to) errors.push('`to` is only meaningful with op=rename.') if (spec.op !== 'add' && (spec.contexte !== undefined || spec.horsPerimetre !== undefined || spec.sources !== undefined)) { errors.push('`contexte` / `horsPerimetre` / `sources` are only written by op=add — a re-context of an existing node is an Edit of its index.md.') } // --- parent shape ----------------------------------------------------------- const required = PARENT_KEYS_BY_LEVEL[spec.level] for (const key of required) { if (!spec.parent[key]) errors.push(`level=${spec.level} requires parent.${key}.`) } for (const key of ['app', 'module', 'section'] as const) { if (spec.parent[key] && !required.includes(key)) errors.push(`level=${spec.level} does not take parent.${key}.`) } if (errors.length > 0) return { valid: false, errors, spec } // --- disk resolution ---------------------------------------------------------- const baRoot = isAbsolute(spec.baRoot) ? spec.baRoot : join(workdir ?? process.cwd(), spec.baRoot) if (!existsSync(baRoot)) { if (spec.op === 'add' && spec.level === 'application') { return { valid: true, errors, spec, scope: { baRoot, parentPath: [], parentDir: baRoot, freshTree: true } } } return { valid: false, errors: [`BA root directory not found: ${baRoot} — only the first \`add level=application\` may create it.`], spec } } const parentPath: string[] = [] if (required.includes('app')) { if (required.includes('module')) { const resolved = resolveBaModuleDir(baRoot, spec.parent.app!, spec.parent.module!) if (!resolved) { const appHit = findDirCaseInsensitive(baRoot, spec.parent.app!) errors.push( appHit ? `Module folder not found: ${spec.parent.app}/${spec.parent.module} under ${baRoot}` : `Application folder not found: ${spec.parent.app} under ${baRoot}`, ) } else { parentPath.push(resolved.app, resolved.module) if (required.includes('section')) { const sec = findDirCaseInsensitive(join(baRoot, resolved.app, resolved.module), spec.parent.section!) if (!sec) errors.push(`Section folder not found: ${spec.parent.section} under ${resolved.app}/${resolved.module}`) else parentPath.push(sec) } } } else { const appHit = findDirCaseInsensitive(baRoot, spec.parent.app!) if (!appHit) errors.push(`Application folder not found: ${spec.parent.app} under ${baRoot}`) else parentPath.push(appHit) } } if (errors.length > 0) return { valid: false, errors, spec } return { valid: true, errors, spec, scope: { baRoot, parentPath, parentDir: join(baRoot, ...parentPath), freshTree: false }, } }