#!/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()