/** * cli:audit-ba — rules/uc.ts (UC-001..023 — module scope; UC-023 project-wide * judgment). Mirrors business-analyse/audit-use-cases/SKILL.md. * UC-022 runs through the SAME engine as the derive-uc-coverage CLI * (exceptionParity, direct import — never a recount by eye). * Hybrid rules emit their mechanical leg + JudgmentItems for the arbitration * side (compact excerpts, never a whole doc). */ import { exceptionParity } from '../../../../create-screen/cli/derive-uc-coverage/execute.js' import { isUcPlaceholderValue, type BaUseCase } from '../../../../../lib/ba-use-cases.js' import type { ModuleModel } from '../corpus/model.js' import { parseIndexDoc } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'use-cases' as const /** * A field is EMPTY when absent or placeholder-only. The predicate itself lives * in lib/ba-use-cases so BR-009 — declared `dedupOf: 'UC-003'` — cannot drift * from its primary (it used to test `mainFlow.length === 0`, which reads the * discovery pass's `—` as a real step). */ const isPlaceholderValue = isUcPlaceholderValue const VAGUE_VERBS = /\b(g[eè]re[rz]?|traite[rz]?|handles?|manages?|process(?:es)?|facilement|easily|rapidement|quickly|efficacement|efficiently|correctement|correctly)\b/i const PLACEHOLDER_STEP = /^(?:todo|tbd|fixme|[aà] compl[eé]ter|a completer|[aà] faire|…|\.{3})$|^.$/i const MODAL_RE = /\b(devrait|devraient|pourrait|should|might|may|peut|peuvent)\b/i const TESTABLE_VAGUE_RE = /\b(g[eè]re|traite|handles?|manages?|supports?)\b/i const NFR_ADJ_RE = /\b(rapidement|intuitif|performant|user-friendly|fast|scalable|responsive)\b/i function allStepFields(uc: BaUseCase): string[] { return [ ...uc.mainFlow, ...uc.preconditions, ...uc.postconditions, ...uc.alternatives.map((b) => b.text), ...uc.exceptions.map((b) => b.text), ] } function normalizeTitle(title: string): string { return fold(title) .replace(/\([^)]*\)\s*$/, '') .replace(/^(le|la|les|un|une|the|a|an)\s+/, '') .replace(/[.!?\s]+$/, '') .trim() } export const UC_RULES: RuleDef[] = [ { id: 'UC-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! return { findings: [ m.ucs.length === 0 ? finding('UC-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : 0 cas d'usage — lancer /ba-create-use-case.`) : finding('UC-001', DIM, 'ok', moduleScope(m), `${m.ucs.length} cas d'usage.`), ], } }, }, { id: 'UC-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const covered = new Set(m.ucs.map((u) => fold(u.sectionFolder))) const empty = m.sections.filter((s) => !covered.has(fold(s))) return { findings: [ empty.length > 0 ? finding('UC-002', DIM, 'err', moduleScope(m), 'Section(s) sans aucun cas d’usage.', empty) : finding('UC-002', DIM, 'ok', moduleScope(m), 'Chaque section a ≥ 1 cas d’usage.'), ], } }, }, { id: 'UC-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const empty = m.ucs.filter((u) => u.mainFlow.filter((s) => !isPlaceholderValue(s)).length === 0).map((u) => u.ucCode) return { findings: [ empty.length > 0 ? finding('UC-003', DIM, 'err', moduleScope(m), 'Cas d’usage sans Flux principal.', empty) : finding('UC-003', DIM, 'ok', moduleScope(m), 'Tous les UC ont un flux principal.'), ], } }, }, { id: 'UC-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const empty = m.ucs.filter((u) => u.preconditions.filter((s) => !isPlaceholderValue(s)).length === 0).map((u) => u.ucCode) return { findings: [ empty.length > 0 ? finding('UC-004', DIM, 'err', moduleScope(m), 'Cas d’usage sans Préconditions.', empty) : finding('UC-004', DIM, 'ok', moduleScope(m), 'Tous les UC ont ≥ 1 précondition.'), ], } }, }, { id: 'UC-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const empty = m.ucs.filter((u) => u.postconditions.filter((s) => !isPlaceholderValue(s)).length === 0).map((u) => u.ucCode) return { findings: [ empty.length > 0 ? finding('UC-005', DIM, 'err', moduleScope(m), 'Cas d’usage sans Postconditions.', empty) : finding('UC-005', DIM, 'ok', moduleScope(m), 'Tous les UC ont ≥ 1 postcondition.'), ], } }, }, { id: 'UC-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const empty = m.ucs.filter((u) => u.alternatives.length === 0 && u.exceptions.length === 0).map((u) => u.ucCode) return { findings: [ empty.length > 0 ? finding('UC-006', DIM, 'err', moduleScope(m), 'Cas d’usage sans flux alternatif NI exception.', empty) : finding('UC-006', DIM, 'ok', moduleScope(m), 'Tous les UC ont ≥ 1 flux alternatif ou exception.'), ], } }, }, { id: 'UC-007', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ module }) { const m = module! const errs: string[] = [] const judgments: JudgmentItem[] = [] for (const uc of m.ucs) { for (const step of allStepFields(uc)) { if (PLACEHOLDER_STEP.test(step.trim())) { errs.push(`${uc.ucCode} : étape placeholder « ${step.trim()} »`) continue } const vague = step.match(VAGUE_VERBS) if (vague) { // A vague verb passes only when followed by a PRECISE object // phrase — a business arbitration. Mechanical err only when the // verb ends the clause (≤ 2 following words); else judgment. const after = step.slice(step.indexOf(vague[0]) + vague[0].length).trim() const followingWords = after.split(/\s+/).filter((w) => w !== '').length if (followingWords <= 2) { errs.push(`${uc.ucCode} : verbe vague sans objet précis « ${step.trim()} »`) } else { judgments.push({ ruleId: 'UC-007', dimension: DIM, scope: moduleScope(m), maxSeverity: 'err', question: `Le verbe vague « ${vague[0]} » est-il suivi d'un objet suffisamment précis dans cette étape de ${uc.ucCode} ?`, excerpts: [step.trim()], }) } } } } const findings: Finding[] = [ errs.length > 0 ? finding('UC-007', DIM, 'err', moduleScope(m), 'Étapes placeholder / verbes vagues sans objet.', errs) : finding('UC-007', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `Aucun placeholder ; ${judgments.length} verbe(s) vague(s) à arbitrer (jugement).` : 'Aucun verbe vague ni placeholder.'), ] return { findings, judgments } }, }, { id: 'UC-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const app = model.apps.find((a) => a.app === m.app) const known = new Set((app?.actors ?? []).map((a) => a.code.toUpperCase())) const orphan: string[] = [] for (const uc of m.ucs) { const refs = [uc.primaryActor ?? '', ...uc.secondaryActors] for (const ref of refs) { for (const code of ref.toUpperCase().matchAll(/BA-[A-Z0-9]+-AC-\d+/g)) { if (!known.has(code[0])) orphan.push(`${uc.ucCode} → ${code[0]}`) } } } return { findings: [ orphan.length > 0 ? finding('UC-008', DIM, 'err', moduleScope(m), 'Référence(s) acteur orphelines (absentes d’acteur.md).', [...new Set(orphan)]) : finding('UC-008', DIM, 'ok', moduleScope(m), 'Toutes les références acteur résolvent.'), ], } }, }, { id: 'UC-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const thin = m.ucs.filter((u) => u.mainFlow.length > 0 && u.mainFlow.length < 3).map((u) => `${u.ucCode} (${u.mainFlow.length} étape(s))`) return { findings: [ thin.length > 0 ? finding('UC-009', DIM, 'err', moduleScope(m), 'Flux principal < 3 étapes (un stub, pas un flux).', thin) : finding('UC-009', DIM, 'ok', moduleScope(m), 'Tous les flux principaux ont ≥ 3 étapes.'), ], } }, }, { id: 'UC-010', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ module }) { const m = module! const pairs: string[] = [] for (let i = 0; i < m.ucs.length; i++) { for (let j = i + 1; j < m.ucs.length; j++) { const a = m.ucs[i]! const b = m.ucs[j]! if (normalizeTitle(a.title) !== '' && normalizeTitle(a.title) === normalizeTitle(b.title)) { pairs.push(`${a.ucCode} ≡ ${b.ucCode} (titres identiques)`) continue } if (a.mainFlow.length >= 3 && b.mainFlow.length >= 3) { const setA = new Set(a.mainFlow.map((s) => fold(s))) const common = b.mainFlow.filter((s) => setA.has(fold(s))).length const ratio = common / Math.max(a.mainFlow.length, b.mainFlow.length) if (ratio >= 0.8) pairs.push(`${a.ucCode} ≡ ${b.ucCode} (${Math.round(ratio * 100)} % d'étapes équivalentes)`) } } } return { findings: [ pairs.length > 0 ? finding('UC-010', DIM, 'err', moduleScope(m), 'Redondance intra-module — fusionner ou supprimer le doublon, garder le plus complet.', pairs) : finding('UC-010', DIM, 'ok', moduleScope(m), 'Aucune redondance intra-module.'), ], } }, }, { id: 'UC-011', dimension: DIM, scope: 'module', kind: 'judgment', evaluate({ module }) { const m = module! const contexte = parseIndexDoc(m.indexRaw).contexte if (m.ucs.length === 0) { return { findings: [finding('UC-011', DIM, 'ok', moduleScope(m), 'Aucun UC — vérification sans objet.')] } } if (contexte === '') { return { findings: [finding('UC-011', DIM, 'warn', moduleScope(m), 'Pas de `## Contexte` dans l’index du module — le périmètre métier des UC n’a pas d’ancre vérifiable.')], } } // Pure business judgment — the CLI prepares COMPACT excerpts (context + // titles), the skill decides. maxSeverity err (blocking rule). const judgments: JudgmentItem[] = [ { ruleId: 'UC-011', dimension: DIM, scope: moduleScope(m), maxSeverity: 'err', question: `Chaque UC reste-t-il dans le périmètre métier du module ${m.module} (charge de la preuve sur le concept étranger) ?`, excerpts: [ `## Contexte du module :\n${contexte.slice(0, 600)}`, ...m.ucs.map((u) => `${u.ucCode} — ${u.title}`), ], }, ] return { findings: [finding('UC-011', DIM, 'ok', moduleScope(m), `Périmètre métier : ${m.ucs.length} UC à arbitrer contre le Contexte (jugement requis).`)], judgments, } }, }, { id: 'UC-012', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const missing = m.ucs.filter((u) => u.level === 'user-goal' && u.acs.length === 0).map((u) => u.ucCode) return { findings: [ missing.length > 0 ? finding('UC-012', DIM, 'err', moduleScope(m), 'UC user-goal sans Acceptance Criteria (l’entrée du test-scaffolder).', missing) : finding('UC-012', DIM, 'ok', moduleScope(m), 'Tous les UC user-goal portent ≥ 1 AC.'), ], } }, }, { id: 'UC-013', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! // The parser is STRICT on the bullet shape: malformed bullets land in // the loss channel — surface them here as the UC-013 err. const lost = m.ucDocs.flatMap((d) => d.lost) return { findings: [ lost.length > 0 ? finding('UC-013', DIM, 'err', moduleScope(m), 'Puce(s) AC malformées ou heading UC near-miss — l’assertion N’EST PAS dans le contrat tant que la forme n’est pas corrigée.', lost) : finding('UC-013', DIM, 'ok', moduleScope(m), 'Toutes les puces AC sont au format `- [ ] AC-NN — …`.'), ], } }, }, { id: 'UC-014', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const issues: string[] = [] for (const uc of m.ucs) { const nums = uc.acs.map((a) => Number(a.localId.slice(3))) for (let i = 0; i < nums.length; i++) { if (nums[i] !== i + 1) { issues.push(`${uc.ucCode} : séquence AC ${uc.acs.map((a) => a.localId).join(', ')} (attendu AC-01..AC-${String(nums.length).padStart(2, '0')} sans trou)`) break } } } return { findings: [ issues.length > 0 ? finding('UC-014', DIM, 'warn', moduleScope(m), 'Numérotation AC non séquentielle (trous/désordre) — casse la numérotation des [Fact].', issues) : finding('UC-014', DIM, 'ok', moduleScope(m), 'Ids AC uniques, séquentiels, sans trou.'), ], } }, }, { id: 'UC-015', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ module }) { const m = module! const judgments: JudgmentItem[] = [] const compound: string[] = [] for (const uc of m.ucs) { for (const ac of uc.acs) { const chains = [...ac.text.matchAll(/\s(et|and|ou|or)\s/gi)] if (chains.length === 0) continue // Two verbs on both sides of the conjunction → likely compound // (mechanical); a single verb → probably a noun phrase (judgment). const halves = ac.text.split(/\s(?:et|and|ou|or)\s/i) const verbish = /\b(renvoie|rejette|affiche|persiste|émet|returns?|rejects?|displays?|persists?|emits?|est|sont|is|are)\b/i if (halves.length >= 2 && verbish.test(halves[0]!) && verbish.test(halves[halves.length - 1]!)) { compound.push(`${uc.ucCode}#${ac.localId} : « ${ac.text.slice(0, 90)} »`) } else { judgments.push({ ruleId: 'UC-015', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `La conjonction chaîne-t-elle deux assertions indépendantes dans ${uc.ucCode}#${ac.localId} (à scinder) ou reste-t-elle dans un syntagme nominal ?`, excerpts: [ac.text], }) } } } return { findings: [ compound.length > 0 ? finding('UC-015', DIM, 'warn', moduleScope(m), 'AC composés (deux clauses indépendantes) — un AC = une assertion, scinder.', compound) : finding('UC-015', DIM, 'ok', moduleScope(m), 'Aucun AC composé détecté mécaniquement.'), ], judgments, } }, }, { id: 'UC-016', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const offending: string[] = [] for (const uc of m.ucs) { for (const ac of uc.acs) { if (MODAL_RE.test(ac.text)) offending.push(`${uc.ucCode}#${ac.localId} : verbe modal (« ${ac.text.match(MODAL_RE)?.[0]} »)`) else if (TESTABLE_VAGUE_RE.test(ac.text) && !/\d|`/.test(ac.text)) offending.push(`${uc.ucCode}#${ac.localId} : verbe vague sans issue observable`) } } return { findings: [ offending.length > 0 ? finding('UC-016', DIM, 'warn', moduleScope(m), 'AC non testables (modal ou vague) — verbe indicatif observable requis.', offending) : finding('UC-016', DIM, 'ok', moduleScope(m), 'Tous les AC utilisent des verbes testables.'), ], } }, }, { id: 'UC-017', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const offending: string[] = [] for (const uc of m.ucs) { for (const ac of uc.acs) { if (NFR_ADJ_RE.test(ac.text) && !/\d/.test(ac.text)) { offending.push(`${uc.ucCode}#${ac.localId} : « ${ac.text.match(NFR_ADJ_RE)?.[0]} » sans seuil chiffré`) } } } return { findings: [ offending.length > 0 ? finding('UC-017', DIM, 'warn', moduleScope(m), 'Adjectif non-fonctionnel sans seuil concret (< 200 ms, ≤ 1 MB, …).', offending) : finding('UC-017', DIM, 'ok', moduleScope(m), 'Aucun adjectif non-fonctionnel non chiffré.'), ], } }, }, { id: 'UC-018', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! if (!m.entityDocExists) { return { findings: [finding('UC-018', DIM, 'ok', moduleScope(m), 'entité.md absent — vérification différée au run post-data-model.')] } } const known = new Set() for (const e of model.graph.entities) { known.add(fold(e.name)) known.add(fold(e.code)) } const offending: string[] = [] for (const uc of m.ucs) { for (const ac of uc.acs) { // COMPOUND PascalCase only (≥ 2 humps) — single capitalised words // are indistinguishable from French sentence-initial words. for (const t of ac.text.matchAll(/\b[A-Z][a-z]+(?:[A-Z][a-z0-9]+)+\b/g)) { if (!known.has(fold(t[0]))) offending.push(`${uc.ucCode}#${ac.localId} → ${t[0]}`) } } } return { findings: [ offending.length > 0 ? finding('UC-018', DIM, 'warn', moduleScope(m), 'AC référençant une entité non déclarée au MCD.', [...new Set(offending)]) : finding('UC-018', DIM, 'ok', moduleScope(m), 'Les entités citées par les AC résolvent au MCD.'), ], } }, }, { id: 'UC-019', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const sections = new Set(m.sections.map((s) => fold(s))) const resources = new Set() for (const s of m.sectionNodes) resources.add(fold(s.folder)) const orphan = m.ucs .filter((u) => u.sectionFolder !== '' && !sections.has(fold(u.sectionFolder)) && !resources.has(fold(u.sectionFolder))) .map((u) => `${u.ucCode} (section « ${u.sectionFolder} »)`) return { findings: [ orphan.length > 0 ? finding('UC-019', DIM, 'err', moduleScope(m), 'Code(s) UC référençant une section supprimée/renommée — /ba-reconcile-menu.', orphan) : finding('UC-019', DIM, 'ok', moduleScope(m), 'Le segment section de chaque code UC existe dans le menu.'), ], } }, }, { id: 'UC-020', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! if (m.rules.length === 0) { return { findings: [finding('UC-020', DIM, 'ok', moduleScope(m), 'Aucune règle en portée — codes d’erreur non vérifiables (BR-001 couvre l’absence).')] } } const declared = new Set(m.rules.map((r) => fold(r.errorCode ?? '')).filter((c) => c !== '')) const offending: string[] = [] for (const uc of m.ucs) { for (const ac of uc.acs) { const candidates = new Set() // Dotted codes anywhere; bare kebab/dotted codes inside backticks // (HTTP status prefix tolerated: `409 projet-offre-deja-soumis`). for (const t of ac.text.matchAll(/\b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b/g)) candidates.add(t[0]) for (const bt of ac.text.matchAll(/`([^`]+)`/g)) { const inner = bt[1]!.trim().replace(/^[1-5]\d{2}\s+/, '') if (/^[a-z][a-z0-9-]*(?:[.-][a-z0-9-]+)+$/.test(inner) && /[.-]/.test(inner)) candidates.add(inner) } for (const c of candidates) { if (!declared.has(fold(c))) offending.push(`${uc.ucCode}#${ac.localId} → \`${c}\``) } } } return { findings: [ offending.length > 0 ? finding('UC-020', DIM, 'warn', moduleScope(m), 'AC assertant un code d’erreur qu’aucune règle du module ne déclare — le test épinglera une chaîne que l’API ne renverra jamais.', [...new Set(offending)]) : finding('UC-020', DIM, 'ok', moduleScope(m), 'Tous les codes d’erreur cités par les AC résolvent vers une règle.'), ], } }, }, { id: 'UC-021', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const IDEMPOTENCE_RE = /relanc|replay|rejou|idempot|m[eê]me p[eé]riode|rien de nouveau|0 new|no new|n'[ée]met rien/i const missing = m.ucs .filter((u) => u.scheduled && !u.acs.some((a) => IDEMPOTENCE_RE.test(a.text))) .map((u) => u.ucCode) return { findings: [ missing.length > 0 ? finding('UC-021', DIM, 'warn', moduleScope(m), 'UC scheduled sans AC d’idempotence (« relancer la même période n’émet rien de nouveau ») — un double-fire double-notifie sans test.', missing) : finding('UC-021', DIM, 'ok', moduleScope(m), 'Chaque UC scheduled porte son AC d’idempotence (ou aucun UC scheduled).'), ], } }, }, { id: 'UC-022', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! // The SAME engine as the derive-uc-coverage CLI — direct import. const parityFindings = exceptionParity(m.ucs) return { findings: [ parityFindings.length > 0 ? finding( 'UC-022', DIM, 'warn', moduleScope(m), 'Flux d’exception sans AC correspondant (parité EXC-N ↔ AC, moteur derive-uc-coverage).', parityFindings.map((f) => `${f.ucCode} : ${f.uncovered.join(', ')} — ${f.detail}`), ) : finding('UC-022', DIM, 'ok', moduleScope(m), 'Parité exception ↔ AC respectée.'), ], } }, }, { id: 'UC-023', dimension: DIM, scope: 'module', kind: 'judgment', evaluate({ model, module }) { const m = module! // Cross-app similarity — candidate pairs found LEXICALLY, arbitration // is business semantics (judgment). Read-only on the other apps. const judgments: JudgmentItem[] = [] const foreign = model.modules.filter((x) => x.app !== m.app) for (const uc of m.ucs) { const local = normalizeTitle(uc.title) if (local.length < 8) continue for (const fm of foreign) { for (const fu of fm.ucs) { if (normalizeTitle(fu.title) === local) { judgments.push({ ruleId: 'UC-023', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `Même objectif métier ou simple homonymie ? ${uc.ucCode} vs ${fu.ucCode} (${fm.app}/${fm.module})`, excerpts: [`${uc.ucCode} — ${uc.title}`, `${fu.ucCode} — ${fu.title}`], }) } } } } return { findings: [ finding( 'UC-023', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} similarité(s) lexicale(s) cross-application à arbitrer (jugement, warn max).` : 'Aucune similarité lexicale cross-application.', ), ], judgments, } }, }, ]