/** * cli:audit-ba — rules/registry.ts * * The declarative rule registry: one RuleDef per audit rule, one file per * dimension. `kind` records the mechanical/hybrid/judgment split — a hybrid * rule emits its mechanical findings AND JudgmentItems for the arbitration * side; a pure-judgment rule emits JudgmentItems only (compact excerpts, the * skill decides). The registry is drift-tested against the ba-audit-* * SKILL.md rule ids. */ import type { AppModel, CorpusModel, ModuleModel } from '../corpus/model.js' import type { Dimension, Finding, FindingAnchor, FindingScope, JudgmentItem, Severity } from '../types.js' export interface RuleCtx { model: CorpusModel /** Set for module-scoped evaluations. */ module?: ModuleModel /** Set for app-scoped evaluations. */ app?: AppModel /** Generated-app root (cross-ref-code); undefined = no code scan. */ projectRoot?: string strict: boolean } export interface RuleResult { findings: Finding[] judgments?: JudgmentItem[] } export interface RuleDef { id: string dimension: Dimension scope: 'project' | 'app' | 'module' kind: 'mechanical' | 'hybrid' | 'judgment' evaluate(ctx: RuleCtx): RuleResult } // --------------------------------------------------------------------------- // Finding helpers — every rule emits ≥1 finding per evaluation (ok included), // the per-dimension verdicts stay complete for ba-audit-pre-dev. // --------------------------------------------------------------------------- /** * Optional, structured extras a rule can attach. * * An OPTIONS object, deliberately — `finding()` already takes five positional * parameters plus two optionals; an eighth would be unreadable at every call * site. Rules opt in only where they can say something (dimension + scope) does * not already say: `anchors` gives the router the offending items as data * rather than as French prose, `file`/`line` override the generic resolution. */ export interface FindingExtras { anchors?: FindingAnchor[] file?: string line?: number /** * A rule this finding is the TWIN/LANDING of — a different predicate on the * same concern. NOT `dedupOf`: `dedupOf` is reserved for the SAME evaluator * serving two ids (XD-005→SCR-003, CODE-005→DM-018, BR-009→UC-003) and is * what the mirror-parity test and /support-report's contradiction detector * read — a divergent `dedupOf` pair is a CLI defect by definition. */ relatedTo?: string } export function finding( ruleId: string, dimension: Dimension, severity: Severity, scope: FindingScope, message: string, evidence?: string[], dedupOf?: string, extras?: FindingExtras, ): Finding { return { ...(extras?.anchors !== undefined && extras.anchors.length > 0 ? { anchors: extras.anchors } : {}), ...(extras?.file !== undefined ? { file: extras.file } : {}), ...(extras?.line !== undefined ? { line: extras.line } : {}), ruleId, dimension, severity, scope, message, ...(evidence !== undefined && evidence.length > 0 ? { evidence } : {}), ...(dedupOf !== undefined ? { dedupOf } : {}), ...(extras?.relatedTo !== undefined ? { relatedTo: extras.relatedTo } : {}), } } export function moduleScope(m: ModuleModel): FindingScope { return { app: m.app, module: m.module } } /** Fold for case/accent-insensitive comparisons (NFD strip + lowercase). */ export function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .toLowerCase() .trim() } /** PascalCase of a camelCase attribute name (`clientId` → `ClientId`). */ export function pascalOf(name: string): string { return name.charAt(0).toUpperCase() + name.slice(1) } /** camelCase of a PascalCase entity name (`BudgetType` → `budgetType`). */ export function camelOf(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) }