/** * cli:audit-ba — rules/state.ts * * State semantics of the data model, shared by the rules that reason about a * lifecycle (XD-001..003, XD-007, DM-025, DM-026): which attribute carries a * state, which VALUES it declares, and whether a text cites one of them. * * Values are the language-neutral anchor: `A_FAIRE`, `TRAITE` are data of the * MCD, written verbatim by the use cases and the rules, whereas an entity's * technical name (`Memo`) never appears in a prose written in the client's * vocabulary (« pense-bête »). A matcher built on the entity name alone cannot * cross that boundary (support report 6819314a0164). */ import type { BaEntity } from '../../../../../lib/ba-entities.js' import { enumValuesOf } from '../../../../../lib/ba-list-split.js' import { fold } from './registry.js' 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 /** Constraint FACETS that are not enum values — `enumValuesOf` falls back to * every top-level segment when no `/`-run exists, so `| Status | enum | requis |` * would otherwise count « requis » as one value. */ export const CONSTRAINT_FACET_WORDS: ReadonlySet = new Set([ 'requis', 'required', 'obligatoire', 'optionnel', 'optional', 'facultatif', 'nullable', 'unique', 'readonly', 'lecture seule', 'pk', 'fk', 'defaut', 'default', ]) export interface StateAttr { name: string enumValues: string[] } export function stateAttrsOf(e: BaEntity): StateAttr[] { const out: StateAttr[] = [] 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 } /** The declared values of ONE state attribute, facet words out, verbatim. */ export function stateValuesOfAttr(a: StateAttr): string[] { return a.enumValues.filter((v) => !CONSTRAINT_FACET_WORDS.has(fold(v))) } /** Every state value an entity declares, across its state attributes, deduplicated (folded identity, first spelling kept). */ export function stateValuesOf(e: BaEntity): string[] { const seen = new Set() const out: string[] = [] for (const a of stateAttrsOf(e)) { for (const v of stateValuesOfAttr(a)) { const f = fold(v) if (seen.has(f)) continue seen.add(f) out.push(v) } } return out } const escapeRe = (v: string): string => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') /** * Does `foldedText` cite the state value as a WHOLE token? `foldedText` must * already be folded (lowercase, accents out) — the caller folds a corpus once, * not once per value. Token boundary = anything outside `[a-z0-9_]`, so * `TRAITE` is found in « au statut `TRAITE` » and not in `retraite`. */ export function citesStateValue(foldedText: string, value: string): boolean { return new RegExp(`(^|[^a-z0-9_])${escapeRe(fold(value))}([^a-z0-9_]|$)`).test(foldedText) } /** The values of `values` that `foldedText` cites, in declaration order. */ export function citedStateValues(foldedText: string, values: readonly string[]): string[] { return values.filter((v) => citesStateValue(foldedText, v)) }