/** * cli:audit-ba — rules/br.ts (BR-001..012 — module scope; BR-005 retired, * BR-012 judgment). Mirrors business-analyse/audit-rules/SKILL.md. * BR-011 defers to the CONV-002 project convention when the corpus is * measured kebab-flat (requirement 8) — `--strict` restores the * per-occurrence errs. */ import { tokenNames, validateFormat } from '../../../../../lib/code-pattern-grammar.js' import type { BaRule } from '../../../../../lib/ba-rules-rows.js' import { undetailedUseCases } from '../../../../../lib/ba-use-cases.js' import { navCodeOf } from '../../../../create-rbac/cli/derive-lookup-grants/derive.js' import type { ModuleModel } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'rules' as const /** BR-008 edge-case themes per ruleType — keyword nets over the examples. */ const EDGE_THEMES: Record = { validation: [/null|vide|empty|absent/i, /min|max|limite|boundary|borne/i, /format/i, /doublon|dupli|casse|case/i], calculation: [/z[eé]ro|zero/i, /n[eé]gatif|negative/i, /arrondi|rounding/i, /division/i], 'state-transition': [/saut|skip|ill[eé]gal/i, /inverse|reverse|retour/i, /terminal|final/i], ownership: [/propri[eé]taire|owner/i, /refus|denied|non-propri|autre/i, /manager|admin|bypass/i], constraint: [/limite|limit|plafond|seuil/i, /au-dessus|dessous|above|below|d[eé]passe/i, /null|z[eé]ro|zero/i], derivation: [/manquant|null|missing|absent/i, /recalcul|recompute|mise [aà] jour|update/i], workflow: [/seuil|threshold|limite/i, /d[eé]l[eé]gation|delegation/i, /timeout|d[eé]lai/i], integrity: [/soft-delete|supprim/i, /cascade/i, /orphelin|orphan/i], 'cross-cutting': [/batch|lot|masse/i, /system|service|compte technique/i, /retry|relance/i], compliance: [/r[eé]tention|expir/i, /r[eé]troactif|retroactive/i], access: [/avec (la )?permission|autoris/i, /sans (la )?permission|refus|denied/i], numbering: [/progress|s[eé]quence|suivant/i, /tenant|isolation/i, /reset|remise [aà] z[eé]ro/i, /concurren|doublon|duplicate/i], } function examplesText(r: BaRule): string { return `${r.validCases ?? ''}\n${r.invalidCases ?? ''}` } export const BR_RULES: RuleDef[] = [ { id: 'BR-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! return { findings: [ m.rules.length === 0 ? finding('BR-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : 0 règle métier — lancer /ba-create-business-rules.`) : finding('BR-001', DIM, 'ok', moduleScope(m), `${m.rules.length} règle(s) métier.`), ], } }, }, { id: 'BR-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const errs: string[] = [] const shells: string[] = [] for (const r of m.rules) { const hasValid = (r.validCases ?? '').trim() !== '' const hasInvalid = (r.invalidCases ?? '').trim() !== '' if (!hasValid && !hasInvalid) { shells.push(r.code) continue } if (!hasValid || !hasInvalid) { errs.push(`${r.code} : exemples incomplets (${hasValid ? 'valide seul' : 'invalide seul'})`) continue } // Error-code rules: the invalid side must carry the expected code. if (r.errorCode && !fold(r.invalidCases!).includes(fold(r.errorCode)) && !/`[^`]+`/.test(r.invalidCases!)) { errs.push(`${r.code} : exemples invalides sans le code d'erreur attendu (\`${r.errorCode}\`)`) } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('BR-002', DIM, 'err', moduleScope(m), 'Exemples incomplets (≥1 valide + ≥1 invalide requis ; code d’erreur attendu sur les règles enforcement).', errs)) if (shells.length > 0) findings.push(finding('BR-002', DIM, 'warn', moduleScope(m), 'Règle(s) sans aucun exemple — encore au stade Level 1.', shells)) if (findings.length === 0) findings.push(finding('BR-002', DIM, 'ok', moduleScope(m), 'Exemples complets sur toutes les règles.')) return { findings } }, }, { id: 'BR-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const byCondition = new Map() for (const r of m.rules) { const cond = fold(r.condition ?? '') if (cond === '') continue if (!byCondition.has(cond)) byCondition.set(cond, []) byCondition.get(cond)!.push(r) } const conflicts: string[] = [] for (const group of byCondition.values()) { if (group.length < 2) continue const exprs = new Set(group.map((r) => fold(r.expression ?? ''))) if (exprs.size > 1) conflicts.push(`${group.map((r) => r.code).join(' vs ')} (même condition, expressions différentes)`) } return { findings: [ conflicts.length > 0 ? finding('BR-003', DIM, 'err', moduleScope(m), 'Règles en conflit.', conflicts) : finding('BR-003', DIM, 'ok', moduleScope(m), 'Aucun conflit de règles.'), ], } }, }, { id: 'BR-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const seen = new Map() const dups: string[] = [] for (const r of m.rules) { const key = `${fold(r.condition ?? '')}|${fold(r.expression ?? '')}` if (key === '|') continue if (seen.has(key)) dups.push(`${seen.get(key)} ≡ ${r.code}`) else seen.set(key, r.code) } return { findings: [ dups.length > 0 ? finding('BR-004', DIM, 'warn', moduleScope(m), 'Règles redondantes (même condition ET même expression).', dups) : finding('BR-004', DIM, 'ok', moduleScope(m), 'Aucune redondance de règles.'), ], } }, }, { id: 'BR-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const unlinked = m.rules.filter((r) => r.linkedUcs.length === 0).map((r) => r.code) return { findings: [ unlinked.length > 0 ? finding('BR-006', DIM, 'warn', moduleScope(m), 'Règle(s) sans « Cas d’usage liés ».', unlinked) : finding('BR-006', DIM, 'ok', moduleScope(m), 'Toutes les règles sont liées à ≥ 1 UC.'), ], } }, }, { id: 'BR-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! if (m.ucs.length === 0) { return { findings: [finding('BR-007', DIM, 'ok', moduleScope(m), 'Aucun UC en portée — couvert par UC-001.')] } } const linked = new Set(m.rules.flatMap((r) => r.linkedUcs.map((u) => u.toUpperCase()))) const uncovered = m.ucs.filter((u) => !linked.has(u.ucCode.toUpperCase())).map((u) => u.ucCode) return { findings: [ uncovered.length > 0 ? finding('BR-007', DIM, 'err', moduleScope(m), 'UC sans aucune règle liée — le code généré n’aurait ni invariant ni chemin d’échec.', uncovered) : finding('BR-007', DIM, 'ok', moduleScope(m), 'Chaque UC est couvert par ≥ 1 règle.'), ], } }, }, { id: 'BR-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const thin: string[] = [] for (const r of m.rules) { const type = fold(r.type ?? '') const themes = EDGE_THEMES[type] if (!themes) continue const text = examplesText(r) if (text.trim() === '') continue // BR-002 owns the no-example case const hits = themes.filter((t) => t.test(text)).length if (hits < 2) thin.push(`${r.code} (${type}) : ${hits}/2 thèmes d'edge-cases couverts`) } return { findings: [ thin.length > 0 ? finding('BR-008', DIM, 'warn', moduleScope(m), 'Exemples ne couvrant pas les edge-cases attendus du ruleType (≥ 2 thèmes requis).', thin) : finding('BR-008', DIM, 'ok', moduleScope(m), 'Edge-cases couverts par ruleType.'), ], } }, }, { id: 'BR-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! // dedupOf UC-003 → the SAME predicate, or the mirror lies. `length === 0` // used to read the discovery pass's `Flux principal : —` as a real step, // so a module of title-only UCs walked straight through this gate. const undetailed = undetailedUseCases(m.ucs) return { findings: [ undetailed.length > 0 ? finding('BR-009', DIM, 'err', moduleScope(m), 'Gate amont : UC non détaillés (flux principal vide) — les règles n’ont rien de concret à contraindre.', undetailed, 'UC-003') : finding('BR-009', DIM, 'ok', moduleScope(m), 'Tous les UC en portée sont détaillés.'), ], } }, }, { id: 'BR-010', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const numbering = m.rules.filter((r) => fold(r.type ?? '') === 'numbering') if (numbering.length === 0) { return { findings: [finding('BR-010', DIM, 'ok', moduleScope(m), 'Aucune règle numbering en portée.')] } } const errs: string[] = [] const warns: string[] = [] for (const r of numbering) { const text = `${r.condition ?? ''}\n${r.expression ?? ''}` const mask = /`([^`]*\{[^`]*)`/.exec(text)?.[1] ?? null const resetMatch = /\b(none|aucun|yearly|annuel(?:le)?|monthly|mensuel(?:le)?|daily|quotidien(?:ne)?)\b/i.exec(text) const reset = resetMatch ? /year|annu/i.test(resetMatch[0]) ? 'Yearly' : /month|mensu/i.test(resetMatch[0]) ? 'Monthly' : /dai|quotid/i.test(resetMatch[0]) ? 'Daily' : 'None' : null if (!mask) errs.push(`${r.code} : format absent (masque backtick avec tokens socle requis)`) else { for (const e of validateFormat(mask, (reset ?? 'None') as 'None' | 'Yearly' | 'Monthly' | 'Daily')) errs.push(`${r.code} : ${e}`) const tokens = new Set(tokenNames(mask)) if (reset === 'Yearly' && !tokens.has('YY') && !tokens.has('YYYY')) errs.push(`${r.code} : reset yearly sans {YY}/{YYYY}`) if (reset === 'Monthly' && !tokens.has('MM')) errs.push(`${r.code} : reset monthly sans {MM}`) if (reset === 'Daily' && !tokens.has('DD')) errs.push(`${r.code} : reset daily sans {DD}`) } if (!/\b(tenant|global)\b/i.test(text)) errs.push(`${r.code} : scope de séquence absent (tenant | global)`) if (reset === null) errs.push(`${r.code} : période de reset absente (none | yearly | monthly | daily)`) if (!/\b(gapless|sans trou|avec trous|hilo)\b/i.test(text)) errs.push(`${r.code} : caractère gapless non spécifié`) const examples = examplesText(r) if (!/\d.*\d/s.test(examples)) errs.push(`${r.code} : exemples sans progression de séquence visible`) // Conditional code-pattern landing leg (mirror of DM-017 check 6). if (m.entityDocExists) { const target = m.entities.find((e) => new RegExp(`\\b${fold(e.name)}\\b`).test(fold(`${r.title} ${r.condition ?? ''} ${r.portee ?? ''}`))) if (target && !target.codePatternRaw) warns.push(`${r.code} : l'entité ${target.name} ne porte pas la ligne **Code pattern** (l'err bloquant est DM-017 check 6)`) } else { warns.push(`${r.code} : atterrissage code-pattern non vérifiable — entité.md absent (DM-017 check 6 le gardera)`) } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('BR-010', DIM, 'err', moduleScope(m), 'Règle(s) numbering incomplètes (format/scope/reset/gapless/exemples).', errs)) if (warns.length > 0) findings.push(finding('BR-010', DIM, 'warn', moduleScope(m), 'Atterrissage code-pattern des règles numbering.', warns, undefined, { relatedTo: 'DM-017' })) if (findings.length === 0) findings.push(finding('BR-010', DIM, 'ok', moduleScope(m), `${numbering.length} règle(s) numbering complètes.`)) return { findings } }, }, { id: 'BR-011', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module, strict }) { const m = module! const enforcement = m.rules.filter((r) => (r.errorCode ?? '') !== '') if (enforcement.length === 0) { return { findings: [finding('BR-011', DIM, 'ok', moduleScope(m), 'Aucune règle enforcement (code d’erreur) en portée.')] } } // Requirement 8: when the WHOLE corpus is measured kebab-flat, the // divergence is CONV-002's single project warn — never an error per // occurrence (the 1 538-code incident). --strict restores the letter. if (!strict && model.conventions.errorCodeShape === 'kebab') { return { findings: [ finding( 'BR-011', DIM, 'ok', moduleScope(m), `Convention projet « codes d'erreur kebab plat » détectée (${Math.round(model.conventions.errorCodeKebabShare * 100)} %) — l'écart vs \`{module}.{entité}.{cas}\` est porté par le warn projet CONV-002, pas par ${enforcement.length} erreurs par occurrence.`, ), ], } } const moduleCode = navCodeOf(m.module) const offending = enforcement .filter((r) => { const code = r.errorCode! const dotted = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,}$/.test(code) return !dotted || code.split('.')[0] !== moduleCode }) .map((r) => `${r.code} : \`${r.errorCode}\``) return { findings: [ offending.length > 0 ? finding('BR-011', DIM, 'err', moduleScope(m), `Code(s) d'erreur hors format \`{module}.{entité}.{cas}\` préfixé « ${moduleCode} » — contrat d'erreur public non namespacé.`, offending) : finding('BR-011', DIM, 'ok', moduleScope(m), 'Tous les codes d’erreur portent le préfixe module au format dotted.'), ], } }, }, { id: 'BR-012', dimension: DIM, scope: 'module', kind: 'judgment', evaluate({ model, module }) { const m = module! const judgments: JudgmentItem[] = [] const foreign = model.modules.filter((x) => x.app !== m.app) for (const r of m.rules) { const localTitle = fold(r.title) if (localTitle.length < 8) continue for (const fm of foreign) { for (const fr of fm.rules) { if (fold(fr.title) === localTitle) { judgments.push({ ruleId: 'BR-012', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `Même contrainte métier re-modélisée ou contexte différent ? ${r.code} vs ${fm.app}/${fm.module} ${fr.code}`, excerpts: [ `${r.code} — ${r.title} | ${r.expression ?? ''}`, `${fm.app}/${fm.module} ${fr.code} — ${fr.title} | ${fr.expression ?? ''}`, ], }) } } } } return { findings: [ finding( 'BR-012', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} similarité(s) de règles cross-application à arbitrer (jugement, warn max).` : 'Aucune similarité lexicale de règles cross-application.', ), ], judgments, } }, }, ]