/** * cli:derive-rbac-grants — validate.ts * * Spec validation + prerequisite checks. The transcription is only meaningful * once at least one module of the processed apps carries an rbac.md (phase 5) * — an empty output must mean "the matrices are empty", never "the inputs * were absent". App folders are validated eagerly so a typo'd `apps` entry is * a hard error, not an empty fragment. */ import { existsSync, readdirSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { DeriveRbacGrantsInputSchema, type ValidationResult } from './types.js' /** Top-level BA app folders: code-cased dirs, `_`/`.` prefixes are workflow. */ export function discoverAppFolders(baRoot: string): string[] { let entries try { entries = readdirSync(baRoot, { withFileTypes: true }) } catch { return [] } return entries .filter( (e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && /^[A-Z0-9]/.test(e.name), ) .map((e) => e.name) .sort() } /** Module folders of an app (same filter — BA modules are UPPERCASE dirs). */ export function discoverModuleFolders(baRoot: string, app: string): string[] { let entries try { entries = readdirSync(join(baRoot, app), { withFileTypes: true }) } catch { return [] } return entries .filter( (e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && /^[A-Z0-9]/.test(e.name), ) .map((e) => e.name) .sort() } export function validateSpec(raw: unknown, workdir?: string): ValidationResult { const parsed = DeriveRbacGrantsInputSchema.safeParse(raw) if (!parsed.success) { return { valid: false, errors: parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`), warnings: [], } } const spec = parsed.data const base = workdir ?? process.cwd() const resolve = (p: string): string => (isAbsolute(p) ? p : join(base, p)) const resolvedBaRoot = resolve(spec.baRoot) const errors: string[] = [] const warnings: string[] = [] if (!existsSync(resolvedBaRoot)) { errors.push(`BA root not found: ${resolvedBaRoot}`) return { valid: false, errors, warnings, spec, resolvedBaRoot } } let appFolders: string[] if (spec.apps && spec.apps.length > 0) { appFolders = [...spec.apps].sort() for (const app of appFolders) { if (!existsSync(join(resolvedBaRoot, app))) { errors.push(`BA app folder not found: ${join(resolvedBaRoot, app)}`) } } } else { appFolders = discoverAppFolders(resolvedBaRoot) if (appFolders.length === 0) { errors.push(`No BA app folder found under ${resolvedBaRoot} — nothing to transcribe.`) } } if (errors.length === 0) { const anyRbac = appFolders.some((app) => discoverModuleFolders(resolvedBaRoot, app).some((m) => existsSync(join(resolvedBaRoot, app, m, 'rbac.md')), ), ) if (!anyRbac) { errors.push( `No //rbac.md found under ${resolvedBaRoot} for apps [${appFolders.join(', ')}] — ` + `run /ba-create-rbac (phase 5) before deriving the grants.`, ) } } let resolvedProjectPath: string | undefined if (spec.mode === 'check') { resolvedProjectPath = resolve(spec.projectPath!) if (!existsSync(resolvedProjectPath)) { errors.push(`projectPath not found: ${resolvedProjectPath}`) } } const resolvedOut = spec.out !== undefined ? resolve(spec.out) : undefined return { valid: errors.length === 0, errors, warnings, spec, resolvedBaRoot, resolvedProjectPath, resolvedOut, appFolders, } }