/** * cli:audit-ba — rules/xd.ts (XD-001..009 — module scope). * Mirrors business-analyse/audit-cross-dimension/SKILL.md. State semantics * (rules/state.ts): 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 type { BaUseCase } from '../../../../../lib/ba-use-cases.js' import { KANBAN_MIN_STATES } from '../../../../../lib/page-spec-kanban.js' import { findEntity, normalizeModulePath } from '../../../../../lib/ba-relations.js' import type { DeclaredRelatedTab } from '../../../../../lib/ba-screens.js' import { formatSensitivity, isSensitive, resolveEntitySensitivity, type ResolvedSensitivity } from '../../../../../lib/ba-sensitivity.js' import type { CorpusModel, ModuleModel } from '../corpus/model.js' import { parseScreenBlocks, unresolvedEntityRefs, type ScreenBlock } from './screen-blocks.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import { citedStateValues, stateAttrsOf, stateValuesOf, stateValuesOfAttr } from './state.js' import type { Finding, JudgmentItem } from '../types.js' export { stateAttrsOf } from './state.js' const DIM = 'cross-dimension' as const 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 // ─── XD-002 — which use case MOVES the state of an entity ───────────────── // // Two questions, kept apart. The historical matcher asked both of the entity's // technical name — `Memo` had to appear in the title or the flow — and a prose // written in the client's vocabulary (« pense-bête ») never carries it: every // French corpus failed, and the only way to go green was to write « (mémo) » // into the use-case titles (support report 6819314a0164). /** Is this use case ABOUT the entity? The entity's own `**Traçabilité**` says * so (the BA's declaration, language-neutral), or its name is in the text. */ function ucLinkedTo(u: BaUseCase, e: BaEntity): boolean { const code = fold(u.ucCode) if (e.traceability.some((t) => fold(t) === code)) return true return new RegExp(`\\b${fold(e.name)}\\b`).test(fold(`${u.title} ${u.mainFlow.join(' ')}`)) } /** Does it DESCRIBE a state change? Its title names a status action, or it * tells a move — a FROM and a TO: its preconditions cite a state value and * its postconditions cite another one. A creation (« créé au statut X », no * starting state) sets a status, it does not move one: that is not proof. */ function ucMovesState(u: BaUseCase, values: readonly string[]): boolean { if (STATUS_ACTION_TITLE_RE.test(u.title)) return true const before = new Set(citedStateValues(fold(u.preconditions.join('\n')), values).map(fold)) if (before.size === 0) return false return citedStateValues(fold(u.postconditions.join('\n')), values).some((v) => !before.has(fold(v))) } /** Every state value the use case cites, wherever it cites it. */ function ucCitedValues(u: BaUseCase, values: readonly string[]): string[] { const text = [ u.title, ...u.mainFlow, ...u.preconditions, ...u.postconditions, ...u.alternatives.map((x) => x.text), ...u.exceptions.map((x) => x.text), ] return citedStateValues(fold(text.join('\n')), values) } 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', // Three outcomes per state-bearing entity, fail-closed: // 1. covered — a linked UC moves the state (mechanical); // 2. judgment — a linked UC CITES a state value without the mechanical // proof of a move (a consultation, a filter… or a change told another // way): the analyst arbitrates, this rule never guesses; // 3. uncovered — no linked UC cites any state: warn. evaluate({ module }) { const m = module! const uncovered: string[] = [] const toArbitrate: string[] = [] const excerpts: string[] = [] for (const e of m.entities) { const attrs = stateAttrsOf(e) if (attrs.length === 0) continue const values = stateValuesOf(e) const linked = m.ucs.filter((u) => ucLinkedTo(u, e)) if (linked.some((u) => ucMovesState(u, values))) continue const citing = linked .map((u) => ({ u, cited: ucCitedValues(u, values) })) .filter((x) => x.cited.length > 0) if (citing.length === 0) { uncovered.push(e.name) continue } toArbitrate.push(e.name) for (const x of citing) { excerpts.push(`${e.name}.${attrs.map((a) => a.name).join('/')} ← ${x.u.ucCode} — ${x.u.title} (cite : ${x.cited.join(', ')})`) } } // ONE item per module: a decision is matched on (rule, scope) alone, so // one item per entity would make two decisions on the same module // indistinguishable. The decision names the entities it leaves uncovered // in its own `evidence`. const judgments: JudgmentItem[] = toArbitrate.length > 0 ? [ { ruleId: 'XD-002', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `${toArbitrate.join(', ')} : pour chaque entité, l’un des cas d’usage cités décrit-il le CHANGEMENT de son état — et non une consultation ou un filtre ? Nommer en evidence les entités qui restent sans cas d’usage de changement.`, excerpts, }, ] : [] const pending = toArbitrate.length > 0 ? ` ${toArbitrate.length} entité(s) à arbitrer (jugement).` : '' 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.${pending}`, uncovered) : finding('XD-002', DIM, 'ok', moduleScope(m), toArbitrate.length > 0 ? `Aucun champ d’état sans cas d’usage candidat.${pending}` : 'Chaque champ d’état a son UC de changement (ou aucun champ d’état).'), ], judgments, } }, }, { id: 'XD-003', dimension: DIM, scope: 'module', kind: 'mechanical', // A board is REQUIRED from KANBAN_MIN_STATES states on — the threshold // /ba-create-screen authors against (levels/kanban-screens.md). Below it a // board stays allowed, never demanded: this rule used to require one for // ANY state field, i.e. the very screen its own remedy skill tells the BA // not to write for a two-value status (support report 6819314a0164). evaluate({ model, module }) { const m = module! const blocks = moduleScreenBlocks(model, m) const uncovered: string[] = [] let inScope = 0 for (const e of m.entities) { const widest = Math.max(0, ...stateAttrsOf(e).map((a) => stateValuesOfAttr(a).length)) if (widest < KANBAN_MIN_STATES) continue inScope++ 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 (≥ ${KANBAN_MIN_STATES} états) sans SmartKanban/board — le workflow n’a aucune vue.`, uncovered) : finding('XD-003', DIM, 'ok', moduleScope(m), inScope > 0 ? `${inScope} entité(s) à cycle de vie (≥ ${KANBAN_MIN_STATES} états), chacune avec sa vue workflow.` : `Aucune entité à cycle de vie (≥ ${KANBAN_MIN_STATES} états) — aucun board exigé.`), ], } }, }, { 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, } }, }, // ─── XD-009 — a 360 tab onto a SENSITIVE entity is declared, never derived ── // // The related tab's data path is the producer's `read` (page-spec-related- // tabs) and, when the target has no list pagespec, derive-related-tabs-data // falls back on « the first 4 attributes of entité.md » — on a payroll or a // medical record that fallback IS the leak. So on a ≥ confidentiel target the // bullet must carry its backticked permission AND (unless it is a summary // band cartouche) its `→ SCR-…` list target. Target resolution reads the // FULL tree (graph + testDataUniverse), never the scoped `model.modules`. { id: 'XD-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const tabs = m.screens.flatMap((s) => s.declaredTabs.map((tab) => ({ screen: s.code, tab }))) if (tabs.length === 0) { return { findings: [finding('XD-009', DIM, 'ok', moduleScope(m), 'Aucun onglet lié déclaré dans le module.')] } } const evidence: string[] = [] let sensitiveTabs = 0 for (const { screen, tab } of tabs) { const target = resolveTabTarget(model, m, tab) if (!target || !isSensitive(target.sensitivity)) continue sensitiveTabs++ const where = `${screen} — onglet « ${tab.label} » → ${tab.relatedEntity} (${target.modulePath}, ${formatSensitivity(target.sensitivity)})` if (!tab.permission) { evidence.push(`${where} : permission backtickée absente — écris la permission \`read\` de la section cible sur la puce (/ba-create-screen), jamais une permission dérivée`) } if (fold(tab.displayMode) !== 'summary' && !tab.targetScreen) { evidence.push(`${where} : cible \`→ SCR-…\` (écran liste) absente — sans pagespec liste, le code retombe sur « les 4 premiers attributs d’entité.md », le point de fuite ; rédige l’écran liste cible (/ba-create-screen)`) } } return { findings: [ evidence.length > 0 ? finding('XD-009', DIM, 'err', moduleScope(m), 'Onglet 360 sur une entité ≥ confidentiel sous-déclaré — la permission et la cible liste sont OBLIGATOIRES sur une cible sensible (lib/ba-sensitivity) : un onglet dérivé ou un fallback d’attributs expose la donnée sans décision.', evidence) : finding('XD-009', DIM, 'ok', moduleScope(m), sensitiveTabs > 0 ? `${sensitiveTabs} onglet(s) lié(s) sur une entité sensible, permission et cible déclarées.` : 'Aucun onglet lié ne vise une entité ≥ confidentiel.'), ], } }, }, ] // ─── XD-009 helper ────────────────────────────────────────────────────────── /** The declared tab's target entity + resolved sensitivity, from the FULL tree; null when unresolved (XD-005 / SCR rules say so). */ function resolveTabTarget(model: CorpusModel, m: ModuleModel, tab: DeclaredRelatedTab): { modulePath: string; sensitivity: ResolvedSensitivity } | null { const candidates = model.graph.entities.filter((e) => e.name === tab.relatedEntity) let chosen = tab.relatedApp ? candidates.find((e) => fold(e.module.split('/')[0] ?? '') === fold(tab.relatedApp!)) : undefined chosen = chosen ?? findEntity(model.graph, tab.relatedEntity, `${m.app}/${m.module}`) if (!chosen) return null const u = (model.testDataUniverse ?? new Map()).get(normalizeModulePath(chosen.module)) if (!u) return null const e = u.entities.find((x) => x.name === chosen!.name) if (!e) return null return { modulePath: u.modulePath, sensitivity: resolveEntitySensitivity(e, u.entities) } } export type { Finding }