#!/usr/bin/env node /** * cli:audit-ba — deterministic whole-project BA audit. * * ONE execution over the whole `.smartstack/ba/` corpus: * * npx --prefer-offline tsx skills/business-analyse/audit-run/cli/audit-ba/index.ts \ * --spec '{"baRoot":".smartstack/ba"}' [--workdir ] * * Loads every doc once (CorpusModel), runs the MECHANICAL audit rules of the * covered dimensions, publishes the parse totals in every output, and fails * CLOSED on parser/control divergence. * * Exit codes (requirement 6 — UNLIKE the derive-* CLIs, findings here ARE the * verdict, so the exit code carries the class): * 0 conforme · 1 warn-only · 2 ≥1 err · 3 parsing suspect · 4 usage/spec. * The envelope keeps `success: true` for 0/1/2 (the audit RAN; findings are * data) and `success: false` for 3/4. * * Writes (atomic, only under `/_audit/`): * - `/_audit/audit-ba.json` — the full machine-readable report * (totals, findings, judgmentNeeded, parseControl, conventions, * rulesetVersion + sourcesHash for staleness detection). * Per-dimension `_audit/.md` verdict rendering lands with the * verdict-md increment; until then the JSON report is the record. */ import { parseArgs } from 'node:util' import { existsSync } from 'node:fs' import { join } from 'node:path' import { executeEnvelope, failExecute, printEnvelope } from '../../../../lib/output.js' import { writeFileAtomic } from '../../../../lib/fs.js' import { loadCorpus } from './corpus/model.js' import { reconcileParse } from './control-counts.js' import { runRules, selfContradictionWarnings } from './engine.js' import { ACTOR_RULES } from './rules/actors.js' import { RBAC_RULES } from './rules/rbac.js' import { DM_RULES } from './rules/dm.js' import { CODE_RULES } from './rules/code.js' import { UC_RULES } from './rules/uc.js' import { BR_RULES } from './rules/br.js' import { SCREEN_RULES } from './rules/screens.js' import { MENU_RULES } from './rules/menu.js' import { SECTION_RULES } from './rules/sections.js' import { XD_RULES } from './rules/xd.js' import { SOURCES_RULES } from './rules/sources.js' import { buildReport, conventionFindings, exitCodeOf, RULESET_VERSION } from './render/report-json.js' import { groupVerdicts, renderProjectSummary, renderVerdict } from './render/verdict-md.js' import { loadJudgments, mergeJudgments } from './judgments.js' import { validateSpec } from './validate.js' import { AUDIT_BA_DIMENSIONS, type AuditBaReport, type Dimension, type Finding } from './types.js' const COMMAND = 'audit-ba' /** Dimensions whose rule packs this version covers. The others are reported * « non auditée » (never a silent 0-finding pass). */ const COVERED_DIMENSIONS: Dimension[] = [...AUDIT_BA_DIMENSIONS] const ALL_RULES = [ ...MENU_RULES, ...SECTION_RULES, ...ACTOR_RULES, ...UC_RULES, ...BR_RULES, ...RBAC_RULES, ...DM_RULES, ...SCREEN_RULES, ...XD_RULES, ...CODE_RULES, ...SOURCES_RULES, ] function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, workdir: { type: 'string' }, }, strict: true, }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(4) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(4) } const validation = validateSpec(raw, values.workdir) if (!validation.valid || !validation.spec || !validation.resolvedBaRoot) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(4) } const spec = validation.spec const baRoot = validation.resolvedBaRoot const model = loadCorpus(baRoot, spec.scope ?? {}) const parseControl = reconcileParse(model) const wanted = spec.dimensions ?? [...AUDIT_BA_DIMENSIONS] const runnable = wanted.filter((d) => COVERED_DIMENSIONS.includes(d)) const skippedDimensions = wanted .filter((d) => !COVERED_DIMENSIONS.includes(d)) .map((d) => ({ dimension: d, reason: 'dimension non couverte par cette version du CLI — reste « non auditée »' })) const engineOut = runRules(ALL_RULES, model, { dimensions: runnable, ...(validation.resolvedProjectRoot !== undefined ? { projectRoot: validation.resolvedProjectRoot } : {}), strict: spec.strict, }) const convFindings = conventionFindings(model, spec.strict) let findings: Finding[] = [...convFindings, ...engineOut.findings] let pending = engineOut.judgments const runWarnings = [...model.parseWarnings] // --judgments: merge the skill's arbitration DECISIONS, consume the // matching pending items, keep the rest visible in the verdicts. if (spec.judgments !== undefined) { const loaded = loadJudgments(spec.judgments) if (loaded.errors.length > 0) { printEnvelope(failExecute(COMMAND, loaded.errors)) process.exit(4) } const merged = mergeJudgments(pending, loaded.decisions) findings = [...findings, ...merged.findings] pending = merged.remaining runWarnings.push(...merged.warnings) } // The run disagreeing with ITSELF (a `dedupOf` mirror in err, its primary ok // on the same scope) is a defect of this CLI — said out loud, never left for // the user to discover as a blocked gate on a valid corpus. runWarnings.push(...selfContradictionWarnings(findings)) // Legacy per-section screen verdicts (conversational era) would DOUBLE-count // next to this run's module-level screen verdicts — say it out loud. const legacyScreenVerdicts = findLegacySectionScreenVerdicts(baRoot, model) if (legacyScreenVerdicts.length > 0) { runWarnings.push( `${legacyScreenVerdicts.length} verdict(s) screen PAR SECTION datant du flux conversationnel détectés — les supprimer pour éviter le double comptage par ba-audit-pre-dev : ${legacyScreenVerdicts.join(', ')}`, ) } const report = buildReport({ model, findings, judgments: pending, parseControl, skippedDimensions, warnings: runWarnings, }) if (!spec.dryRun) { void writeOutputs(baRoot, report, pending) .then((filesModified) => finish(report, filesModified)) .catch((e) => { printEnvelope(failExecute(COMMAND, [`Cannot write audit outputs: ${(e as Error).message}`])) process.exit(4) }) return } finish(report, []) } /** Existing `
/_audit/screen.md` files under the audited modules. */ function findLegacySectionScreenVerdicts(baRoot: string, model: ReturnType): string[] { const out: string[] = [] for (const m of model.modules) { for (const s of m.sections) { const p = join(baRoot, m.app, m.module, s, '_audit', 'screen.md') if (existsSync(p)) out.push(`${m.app}/${m.module}/${s}/_audit/screen.md`) } } return out } /** Write the JSON report + every per-dimension verdict + the project summary * (atomic writes, only under `_audit/`). Returns the modified paths. */ async function writeOutputs( baRoot: string, report: AuditBaReport, pending: AuditBaReport['judgmentNeeded'], ): Promise { const date = new Date().toISOString().slice(0, 10) const files: string[] = [] const opts = { date, rulesetVersion: RULESET_VERSION, sourcesHash: report.sourcesHash } const jsonPath = join(baRoot, '_audit', 'audit-ba.json') await writeFileAtomic(jsonPath, JSON.stringify(report, null, 2) + '\n') files.push(jsonPath) for (const target of groupVerdicts(report.findings, pending)) { const p = join(baRoot, ...target.relPath.split('/')) await writeFileAtomic(p, renderVerdict(target, opts)) files.push(p) } const projectFindings = report.findings.filter( (f) => !f.ruleId.startsWith('CONV-') && f.scope.app === undefined && f.scope.module === undefined, ) const summaryPath = join(baRoot, '_audit', 'audit-ba.md') await writeFileAtomic( summaryPath, renderProjectSummary({ date, rulesetVersion: RULESET_VERSION, sourcesHash: report.sourcesHash, totalsLine: `${report.totals.ucs} UC · ${report.totals.acs} AC · ${report.totals.rules} règles · ${report.totals.errorCodes} codes d'erreur · ${report.totals.entities} entités · ${report.totals.screens} écrans`, conventionFindings: report.findings.filter((f) => f.ruleId.startsWith('CONV-')), projectFindings, parseControlStatus: report.parseControl.status, pendingCount: pending.length, exitClass: report.exitClass, }), ) files.push(summaryPath) return files } function finish(report: AuditBaReport, filesModified: string[]): void { const suspect = report.parseControl.status !== 'ok' printEnvelope( executeEnvelope(COMMAND, { success: report.exitClass !== 'parse-suspect', data: { exitClass: report.exitClass, ...report.counts, ...report.totals, parseControl: report.parseControl.status, skipped: report.skippedDimensions.length, filesModified, }, report, warnings: suspect ? report.parseControl.perDoc .filter((d) => d.status !== 'ok') .map((d) => `parse-${d.status}: ${d.relPath} [${d.counter}] contrôle=${d.control} parsé=${d.parsed}`) : [], nextSteps: report.exitClass === 'parse-suspect' ? [ 'PARSING SUSPECT — aucun verdict vert ne peut naître d’un parseur muet : corriger le parseur ou la forme du doc AVANT de lire les findings.', ] : report.counts.err > 0 ? ['Des bloquants existent — corriger via les skills /ba-create-* nommés par chaque finding, puis relancer.'] : ['Audit mécanique terminé — les règles de jugement restent aux skills /ba-audit-* (judgmentNeeded[]).'], }), ) process.exit(exitCodeOf(report.exitClass)) } main()