/** * cli:audit-ba — rules/xd.ts (XD-001..008 — module scope). * Mirrors business-analyse/audit-cross-dimension/SKILL.md. State semantics: * an attribute whose name contains status/state/phase/step, or an enum whose * values read as a lifecycle. */ import type { BaEntity } from '../../../../../lib/ba-entities.js' import { enumValuesOf } from '../../../../../lib/ba-list-split.js' import type { ModuleModel } from '../corpus/model.js' import { parseScreenBlocks, unresolvedEntityRefs, type ScreenBlock } from './screen-blocks.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'cross-dimension' as const const STATE_NAME_RE = /status|statut|state|etat|état|phase|step/i const LIFECYCLE_ENUM_RE = /(NOUVELLE|DRAFT|BROUILLON|OPEN|OUVERT|ACTIVE|EN_COURS|PENDING|APPROVED|VALIDE|REJECTED|REFUSE|CLOSED|CLOTURE|TERMINE|GAGNEE|PERDUE|ARCHIVE)/i export function stateAttrsOf(e: BaEntity): { name: string; enumValues: string[] }[] { const out: { name: string; enumValues: string[] }[] = [] for (const a of e.attributes) { const isEnum = /enum/i.test(a.type) // Only the `/`-bearing top-level segments are value runs — a facet after a // comma (`draft/submitted/approved, requis`) must not yield a phantom value. const values = isEnum ? enumValuesOf(a.constraints) : [] if (STATE_NAME_RE.test(a.name) || (isEnum && LIFECYCLE_ENUM_RE.test(a.constraints))) { out.push({ name: a.name, enumValues: values }) } } return out } function moduleScreenBlocks(model: { docs: { kind: string; app?: string; module?: string; relPath: string; raw: string }[] }, m: ModuleModel): ScreenBlock[] { return model.docs .filter((d) => d.kind === 'screen' && d.app === m.app && d.module === m.module) .flatMap((d) => parseScreenBlocks(d.raw, d.relPath)) } const STATUS_ACTION_TITLE_RE = /\b(change|update|transition|approve|approuv|reject|rejet|refus|validat|valide|cancel|annul|cl[oô]tur|archiv|suspend|activ)/i export const XD_RULES: RuleDef[] = [ { id: 'XD-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const uncovered: string[] = [] for (const e of m.entities) { if (stateAttrsOf(e).length === 0) continue const covered = m.rules.some( (r) => (fold(r.type ?? '') === 'state-transition' || r.flow.length > 0 || /statut|status|state|transition/i.test(r.condition ?? '')) && new RegExp(`\\b${fold(e.name)}\\b`).test(fold(`${r.title} ${r.condition ?? ''} ${r.expression ?? ''} ${r.portee ?? ''}`)), ) if (!covered) uncovered.push(e.name) } return { findings: [ uncovered.length > 0 ? finding('XD-001', DIM, 'warn', moduleScope(m), 'Champ(s) d’état sans règle de transition — les changements de statut autorisés ne sont définis nulle part.', uncovered) : finding('XD-001', DIM, 'ok', moduleScope(m), 'Chaque champ d’état a sa règle de transition (ou aucun champ d’état).'), ], } }, }, { id: 'XD-002', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ module }) { const m = module! const uncovered: string[] = [] for (const e of m.entities) { if (stateAttrsOf(e).length === 0) continue const covered = m.ucs.some( (u) => STATUS_ACTION_TITLE_RE.test(u.title) && new RegExp(`\\b${fold(e.name)}\\b`).test(fold(`${u.title} ${u.mainFlow.join(' ')}`)), ) if (!covered) uncovered.push(e.name) } return { findings: [ uncovered.length > 0 ? finding('XD-002', DIM, 'warn', moduleScope(m), 'Champ(s) d’état sans UC décrivant le changement — un statut que rien ne bouge est un état mort.', uncovered) : finding('XD-002', DIM, 'ok', moduleScope(m), 'Chaque champ d’état a son UC de changement (ou aucun champ d’état).'), ], } }, }, { id: 'XD-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const blocks = moduleScreenBlocks(model, m) const uncovered: string[] = [] for (const e of m.entities) { if (stateAttrsOf(e).length === 0) continue const hasBoard = blocks.some((b) => /kanban|board/i.test(b.screenType) && fold(b.entity ?? '') === fold(e.name)) if (!hasBoard) uncovered.push(e.name) } return { findings: [ uncovered.length > 0 ? finding('XD-003', DIM, 'warn', moduleScope(m), 'Entité(s) à cycle de vie sans SmartKanban/board — le workflow n’a aucune vue.', uncovered) : finding('XD-003', DIM, 'ok', moduleScope(m), 'Chaque entité à état a sa vue workflow (ou aucune entité à état).'), ], } }, }, { id: 'XD-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const rbacActors = new Set(m.rbacRows.map((r) => r.actorCode.toUpperCase())) const orphan: string[] = [] for (const uc of m.ucs) { const code = uc.primaryActor?.toUpperCase().match(/BA-[A-Z0-9]+-AC-\d+/)?.[0] if (code && !rbacActors.has(code)) orphan.push(`${uc.ucCode} → ${code}`) } return { findings: [ orphan.length > 0 ? finding('XD-004', DIM, 'warn', moduleScope(m), 'Acteur(s) principal d’UC sans aucune permission dans le rbac.md du module.', [...new Set(orphan)]) : finding('XD-004', DIM, 'ok', moduleScope(m), 'Chaque acteur principal d’UC porte ≥ 1 permission.'), ], } }, }, { id: 'XD-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! // Mirror of SCR-003 — the SAME evaluator (screen-blocks.unresolvedEntityRefs), // hub exemption included. Never re-implement it here. const broken = unresolvedEntityRefs(moduleScreenBlocks(model, m), m.entities) return { findings: [ broken.length > 0 ? finding('XD-005', DIM, 'err', moduleScope(m), 'Écran(s) liant une entité absente du MCD (miroir de SCR-003).', broken, 'SCR-003') : finding('XD-005', DIM, 'ok', moduleScope(m), 'Toutes les liaisons écran → entité résolvent.'), ], } }, }, { id: 'XD-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const blocks = moduleScreenBlocks(model, m) const bySection = new Map() for (const b of blocks) { if (!bySection.has(b.file)) bySection.set(b.file, []) bySection.get(b.file)!.push(b) } const offending: string[] = [] for (const b of blocks) { if (!/kanban/i.test(b.screenType)) continue const sameSection = bySection.get(b.file) ?? [] const hasList = sameSection.some((x) => /listview/i.test(x.screenType) && fold(x.entity ?? '') === fold(b.entity ?? '')) if (!hasList) { const listElsewhere = blocks.find((x) => /listview/i.test(x.screenType) && fold(x.entity ?? '') === fold(b.entity ?? '')) offending.push( `${b.code} (${b.file})${listElsewhere ? ` — la liste de ${b.entity} vit dans ${listElsewhere.file}` : ` — aucune SmartListView de ${b.entity ?? '?'}`}`, ) } } return { findings: [ offending.length > 0 ? finding('XD-006', DIM, 'err', moduleScope(m), 'Kanban NON co-localisé avec la SmartListView de la même entité — un kanban est une représentation, jamais une section.', offending) : finding('XD-006', DIM, 'ok', moduleScope(m), 'Chaque kanban est co-localisé avec sa liste.'), ], } }, }, { id: 'XD-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const evidence: string[] = [] const blocks = moduleScreenBlocks(model, m) // Leg 1: every Cycle-de-vie phase status is an enum value of the bound // entity AND reachable by ≥ 1 Flow transition. for (const b of blocks) { if (!b.hasCycleDeVie || b.cycleStatuses.length === 0) continue const entity = m.entities.find((e) => fold(e.name) === fold(b.entity ?? '')) if (!entity) continue const enumValues = new Set(stateAttrsOf(entity).flatMap((s) => s.enumValues.map((v) => v.toUpperCase()))) const reached = new Set(m.rules.flatMap((r) => r.flow.map((f) => f.to.toUpperCase()))) for (const status of new Set(b.cycleStatuses)) { if (enumValues.size > 0 && !enumValues.has(status)) evidence.push(`${b.code} : phase sur « ${status} » — pas une valeur d'enum de ${entity.name}`) else if (reached.size > 0 && !reached.has(status)) evidence.push(`${b.code} : phase sur « ${status} » — aucune transition Flow ne l'atteint (la phase ne peut jamais s'ouvrir)`) } } return { findings: [ evidence.length > 0 ? finding('XD-007', DIM, 'warn', moduleScope(m), 'Phases de cycle de vie incohérentes avec l’enum/les transitions Flow (le jumeau déterministe complet est PRD-120, derive-lifecycle).', evidence, undefined, { relatedTo: 'SCR-019' }) : finding('XD-007', DIM, 'ok', moduleScope(m), 'Phases de cycle de vie cohérentes avec les Flow (ou aucune phase déclarée).'), ], } }, }, { id: 'XD-008', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const RENEWAL_RE = /reconduct|renew|transfer|transf[eé]r|changer|renouvel/i const judgments: JudgmentItem[] = [] const blocks = moduleScreenBlocks(model, m) for (const e of m.entities) { const names = new Set(e.attributes.map((a) => fold(a.name))) const hasPeriodPair = (names.has('startdate') && names.has('enddate')) || (names.has('validfrom') && names.has('validto')) || (names.has('datedebut') && names.has('datefin')) if (!hasPeriodPair) continue const entityBlocks = blocks.filter((b) => fold(b.entity ?? '') === fold(e.name)) const hasCreateForm = entityBlocks.some((b) => /form/i.test(b.screenType) && (b.mode === undefined || /create/i.test(b.mode))) const renewalActions = blocks.flatMap((b) => b.actions).filter((a) => RENEWAL_RE.test(`${a.code ?? ''} ${a.label ?? ''}`)) const plainCreate = blocks.flatMap((b) => b.actions).some((a) => /^(add|create|ajouter|ouvrir|open)/i.test(a.code ?? '')) if (!hasCreateForm && !plainCreate && renewalActions.length > 0) { judgments.push({ ruleId: 'XD-008', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `${e.name} (paire de période) : sa PREMIÈRE période est-elle créable autrement que par une action de renouvellement/transition ?`, excerpts: renewalActions.map((a) => a.raw), }) } } return { findings: [ finding('XD-008', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} entité(s) datées à naissance suspecte à arbitrer (jugement).` : 'Aucune entité datée sans naissance.'), ], judgments, } }, }, ] export type { Finding }