/** * cli:derive-change-impact — validate.ts * * Stage 1: Zod (v4, strict). Stage 2: the scope must resolve on disk — * baRoot, then app + module (case-insensitive, lib/ba-rbac-rows * resolveBaModuleDir), then the section folder when given. Spec-shape * mistakes (op=modify without a code, mode=verify without `verify`) are * usage errors (exit 1) — everything about the CONTENT of the scope 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 { ChangeImpactInputSchema, type ValidationResult } from './types.js' 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 } } export function validateSpec(raw: unknown, workdir?: string): ValidationResult { const parsed = ChangeImpactInputSchema.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[] = [] if (spec.op === 'modify' && !spec.target.code && spec.kind !== 'permission' && spec.kind !== 'attribute') { errors.push('op=modify requires target.code (the code of the item to modify).') } if (spec.op === 'modify' && spec.kind === 'permission' && (!spec.target.actor || !spec.target.permissionPath)) { errors.push('op=modify on a permission requires target.actor AND target.permissionPath (the row is a tuple, not a code).') } if (spec.mode === 'verify' && !spec.verify) { errors.push('mode=verify requires the `verify` object ({ expectCode, baselineCount, machineBlocksHash? }).') } if (spec.mode === 'verify' && spec.kind === 'permission' && !spec.target.actor) { errors.push('mode=verify on a permission requires target.actor (verify.expectCode is the permission path).') } if (spec.kind === 'attribute' && (!spec.target.entity || !spec.target.attribute)) { errors.push('kind=attribute requires target.entity (PascalCase) AND target.attribute (the attribute name).') } if (spec.kind === 'entity' && spec.op === 'add' && !spec.target.entity && !spec.target.title) { errors.push('kind=entity requires target.entity (the PascalCase name of the new entity).') } if (spec.kind === 'screen' && spec.op === 'modify' && !spec.target.code) { errors.push('op=modify on a screen requires target.code (the SCR code).') } const baRoot = isAbsolute(spec.baRoot) ? spec.baRoot : join(workdir ?? process.cwd(), spec.baRoot) if (!existsSync(baRoot)) { errors.push(`BA root directory not found: ${baRoot}`) return { valid: false, errors, spec } } const resolved = resolveBaModuleDir(baRoot, spec.app, spec.module) if (!resolved) { errors.push(`Module folder not found: ${spec.app}/${spec.module} under ${baRoot}`) return { valid: false, errors, spec } } let section: string | undefined if (spec.section) { const hit = findDirCaseInsensitive(join(baRoot, resolved.app, resolved.module), spec.section) if (!hit) { errors.push(`Section folder not found: ${spec.section} under ${resolved.app}/${resolved.module} — a new section is /ba-create-menu's job, not a change.`) } else { section = hit } } if (errors.length > 0) return { valid: false, errors, spec } return { valid: true, errors, spec, scope: { baRoot, app: resolved.app, module: resolved.module, ...(section ? { section } : {}) }, } }