/** * cli:audit-ba — rules/dm.ts (DM-001..032 — module scope, all mechanical). * Mirrors business-analyse/audit-data-model/SKILL.md. Catalogues delegated to * lib/core-catalog, lib/capability-catalog, lib/fk-allowlist, * lib/display-field, lib/code-pattern-grammar (all drift-tested). */ import { findCodeLikeAttributes, isAttributeRowLoss, type BaEntity } from '../../../../../lib/ba-entities.js' import { enumValuesOf } from '../../../../../lib/ba-list-split.js' import { isTenantColumn, knownIndexColumns } from '../../../../../lib/entity-columns.js' import { referencedFields } from '../../../../../lib/code-pattern-grammar.js' import type { CorpusModel } from '../corpus/model.js' import { isAuditAllowlisted } from '../../../../../lib/fk-allowlist.js' import { COMPANY_IDENTITY_ATTRIBUTES, PERSON_IDENTITY_ATTRIBUTES, matchCoreEntity, matchPersonTrigger, matchReservedCoreName, } from '../../../../../lib/core-catalog.js' import { matchCapabilityAttribute, matchCapabilityTrigger } from '../../../../../lib/capability-catalog.js' import { PREFERRED_DISPLAY_FIELDS } from '../../../../../lib/display-field.js' import { findCodePatternNearMisses, findDecidedCodeNearMisses, hasSequence, tokenNames, validateFormat, } from '../../../../../lib/code-pattern-grammar.js' import { acceptanceCitationSources, citationsFor, classifyReferentialCode, initialValuesSources, isLookupClassification, isReferentialCodeError, prdCitationSources, ruleCitationSources, type ReferentialCodeVerdict, } from '../../../../../lib/ba-referential-codes.js' import type { BaRule } from '../../../../../lib/ba-rules-rows.js' import { deriveTestData, type DeriveTestDataReport } from '../../../../../lib/ba-test-data-check.js' import { TEST_DATA_FILE } from '../../../../../lib/ba-test-data.js' import type { ModuleModel } from '../corpus/model.js' import type { ModuleModel } from '../corpus/model.js' import { camelOf, finding, fold, moduleScope, pascalOf, type RuleDef } from './registry.js' import { stateAttrsOf } from './xd.js' import { parseScreenBlocks, type ScreenBlock } from './screen-blocks.js' import type { FindingAnchor, Finding } from '../types.js' const DIM = 'data-model' as const const PRIMITIVE_TOKENS = new Set([ 'DateTime', 'DateOnly', 'TimeOnly', 'String', 'Boolean', 'Math', 'Convert', 'Int32', 'Decimal', 'Guid', 'TimeSpan', ]) /** One evaluator per rule; helpers keep each check close to its SKILL prose. */ function classificationOf(e: BaEntity): string { return fold(e.classification ?? '') } // Delegated: DM-012, DM-015 and DM-022 must never drift on what a lookup is. const isLookup = (e: BaEntity): boolean => isLookupClassification(e.classification) const isTechnical = (e: BaEntity): boolean => classificationOf(e).includes('technical') || classificationOf(e).includes('technique') const isComponent = (e: BaEntity): boolean => classificationOf(e).includes('composant') || classificationOf(e).includes('component') function attrByFoldedName(e: BaEntity, name: string) { return e.attributes.find((a) => fold(a.name) === fold(name)) } /** Whole-token PascalCase tokens of an expression (lowercase tail required — * skips ALL-CAPS enum literals). */ function pascalTokens(text: string): string[] { return [...text.matchAll(/\b[A-Z][a-z][A-Za-z0-9]*\b/g)].map((m) => m[0]) } /** Incoming relations of an entity across the whole project graph. */ function incomingCount(m: ModuleModel, e: BaEntity, model: { graph: { relations: { targetEntity: string }[] } }): number { return model.graph.relations.filter((r) => r.targetEntity === e.name).length } const ALLOC_NAME_RE = /(sequence|séquence|compteur|counter|numerotation|numérotation)$/i /** Every SCR block of the module's screen.md docs (same walk as rules/xd.ts — * kept local so this file stays committable on its own). */ 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)) } /** 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. */ const CONSTRAINT_FACET_WORDS = new Set([ 'requis', 'required', 'obligatoire', 'optionnel', 'optional', 'facultatif', 'nullable', 'unique', 'readonly', 'lecture seule', 'pk', 'fk', 'defaut', 'default', ]) /** Framework names the generator DROPS if listed (generate.ts `reserved`, minus * `Id` which the authoring contract sanctions in the table). */ const FRAMEWORK_DROPPED = new Set(['createdat', 'updatedat', 'deletedat', 'tenantid']) /** Framework names attributes.md USED TO claim as implicit while the generated * base class does NOT provide them — listed, they become real columns. */ const FRAMEWORK_NOT_PROVIDED = new Set(['createdby', 'updatedby', 'scope']) const escapeRe = (v: string): string => v.replace(/[.*+?^${}()|[\]\\]/g, '\\export const DM_RULES: RuleDef[] = [') /** The DM-015 convention set of a lookup — DM-015 DEMANDS these fields, so * DM-025 must never propose deleting them (the DM-012↔DM-022 arbitration * failure, avoided by construction). */ const LOOKUP_CONVENTION_FIELDS = new Set(['label', 'name', 'isactive', 'sortorder', 'code']) /** * DM-025's evidence: what DESCRIBES an attribute, gathered ONCE per module. * - `corpus`: the folded text every whole-word mention is searched in — UCs * (title, flows, pre/postconditions, ACs), rules (title, condition, * expression, cases, portée), the RAW screen.md docs (columns, filters, * fields, actions… whatever grammar they use), rbac.md, the module and * section index.md; * - `names`: folded attribute names STRUCTURALLY referenced — FK columns of * every relation of the module, index columns, PascalCase tokens of every * Calculé formula, the members a **Dérivé** projects, **Valeurs * initiales** header cells, **Affichage**, **Code saisi**, the fields a * **Code pattern** mask derives from. * Exported for its own test: the exemption list is where the bugs would live. */ export function attributeEvidence(model: CorpusModel, m: ModuleModel): { corpus: string; names: Set } { const text: string[] = [] for (const uc of m.ucs) { text.push(uc.title, ...uc.mainFlow, ...uc.preconditions, ...uc.postconditions) text.push(...uc.alternatives.map((b) => b.text), ...uc.exceptions.map((b) => b.text), ...uc.acs.map((a) => a.text)) } for (const r of m.rules) text.push(r.title, r.condition ?? '', r.expression ?? '', r.validCases ?? '', r.invalidCases ?? '', r.portee ?? '') for (const d of model.docs) if (d.kind === 'screen' && d.app === m.app && d.module === m.module) text.push(d.raw) if (m.rbacContent) text.push(m.rbacContent) if (m.indexRaw) text.push(m.indexRaw) for (const s of m.sectionNodes) if (s.indexRaw) text.push(s.indexRaw) const names = new Set() const add = (n: string | null | undefined): void => { if (n) names.add(fold(n)) } for (const e of m.entities) { for (const r of e.relations) add(r.fk) for (const idx of e.indexes) for (const f of idx.fields) add(f) for (const a of e.attributes) if (a.computed) for (const t of pascalTokens(a.computed)) add(t) for (const d of e.derivedRaw) { const nav = /nav\s+[A-Za-z0-9_]+\.([A-Za-z0-9_]+)/.exec(d) if (nav) add(nav[1]) const sel = /select\s+([A-Za-z0-9_]+)/.exec(d) if (sel) add(sel[1]) const pick = /(?:latest|open(?:-period)?)\(([A-Za-z0-9_]+)\)/.exec(d) if (pick) add(pick[1]) } for (const c of e.initialValues?.columns ?? []) add(c) add(e.display) add(e.fields['code saisi'] ?? e.fields['typed code']) if (e.codePattern?.format) for (const f of referencedFields(e.codePattern.format)) add(f) } return { corpus: fold(text.join('\n')), names } } export const DM_RULES: RuleDef[] = [ { id: 'DM-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! return { findings: [ m.entities.length === 0 ? finding('DM-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : 0 entité déclarée — le module ne peut exposer aucune donnée. Lancer /ba-create-data-model.`) : finding('DM-001', DIM, 'ok', moduleScope(m), `${m.entities.length} entité(s) déclarée(s).`), ], } }, }, { id: 'DM-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const empty = m.entities.filter((e) => e.attributes.length === 0).map((e) => e.name) return { findings: [ empty.length > 0 ? finding('DM-002', DIM, 'err', moduleScope(m), 'Entité(s) sans aucun attribut.', empty) : finding('DM-002', DIM, 'ok', moduleScope(m), 'Toutes les entités ont ≥ 1 attribut.'), ], } }, }, { id: 'DM-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! // The whole contract of attributes.md § Quality checks — « every string // has a length, every decimal precision/scale, every enum non-empty // values ». This rule used to implement the string third only: a bare // `decimal` gets no .HasPrecision and SQL Server falls back to // decimal(18,2) — a declared decimal(5,4) silently loses two decimals. const offending: string[] = [] for (const e of m.entities) { for (const a of e.attributes) { // A computed member has NO column (generate.ts drops `formula` // fields) — nothing to size. It used to err here for nothing. if (a.computed !== null) continue const type = a.type.trim() if (/^string/i.test(type)) { const inType = /string\s*\(\s*(\d+)/i.exec(type) const inConstraints = /max(?:length)?\s*[:=]?\s*(\d+)/i.exec(a.constraints) const len = inType ? Number(inType[1]) : inConstraints ? Number(inConstraints[1]) : 0 if (!(len > 0)) offending.push(`${e.name}.${a.name} : string sans maxLength (> 0) — nvarchar(max)`) } else if (/^decimal/i.test(type)) { if (!/^decimal\s*\(\s*\d+\s*,\s*\d+\s*\)/i.test(type)) { offending.push(`${e.name}.${a.name} : decimal sans (précision, échelle) — SQL Server retombe sur decimal(18,2)`) } } else if (/^enum/i.test(type)) { const values = enumValuesOf(a.constraints).filter((v) => !CONSTRAINT_FACET_WORDS.has(fold(v))) if (values.length === 0) offending.push(`${e.name}.${a.name} : enum sans valeurs (A/B/C dans Contraintes)`) } } } return { findings: [ offending.length > 0 ? finding('DM-003', DIM, 'err', moduleScope(m), 'Type(s) d’attribut incomplet(s) — string sans maxLength, decimal sans précision/échelle, enum sans valeurs : perte silencieuse côté SQL.', offending) : finding('DM-003', DIM, 'ok', moduleScope(m), 'Tous les types sont complets (string maxLength, decimal (p,s), enum valeurs).'), ], } }, }, { id: 'DM-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const moduleNames = new Set(m.entities.map((e) => e.name)) const projectNames = new Set(model.graph.entities.map((e) => e.name)) const broken: string[] = [] for (const e of m.entities) { for (const r of e.relations) { if (r.scope === 'core') continue // CODE-003/DM-018 own the core leg const pool = r.scope === 'same-module' ? moduleNames : projectNames if (!pool.has(r.targetEntity)) broken.push(`${e.name} → ${r.targetEntity} (${r.scope})`) } } return { findings: [ broken.length > 0 ? finding('DM-004', DIM, 'err', moduleScope(m), 'Cible(s) de relation introuvable(s) dans le MCD.', broken) : finding('DM-004', DIM, 'ok', moduleScope(m), 'Toutes les cibles de relations résolvent.'), ], } }, }, { id: 'DM-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! // DFS cycle detection over the module-internal edges (self-refs skipped). const names = new Set(m.entities.map((e) => e.name)) const edges = new Map() for (const e of m.entities) { edges.set( e.name, e.relations.filter((r) => r.targetEntity !== e.name && names.has(r.targetEntity)).map((r) => r.targetEntity), ) } const cycles: string[] = [] const state = new Map() const stack: string[] = [] const visit = (n: string): void => { state.set(n, 'visiting') stack.push(n) for (const t of edges.get(n) ?? []) { if (state.get(t) === 'visiting') { cycles.push([...stack.slice(stack.indexOf(t)), t].join(' → ')) } else if (!state.has(t)) visit(t) } stack.pop() state.set(n, 'done') } for (const n of edges.keys()) if (!state.has(n)) visit(n) return { findings: [ cycles.length > 0 ? finding('DM-005', DIM, 'warn', moduleScope(m), 'Chaîne(s) de relations circulaire(s) (les auto-références sont exclues).', [...new Set(cycles)]) : finding('DM-005', DIM, 'ok', moduleScope(m), 'Aucun cycle de relations.'), ], } }, }, { id: 'DM-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const bad = m.entities.filter((e) => !/^[A-Z][A-Za-z0-9]*$/.test(e.name)).map((e) => e.name) return { findings: [ bad.length > 0 ? finding('DM-006', DIM, 'warn', moduleScope(m), 'Nom(s) d’entité hors PascalCase.', bad) : finding('DM-006', DIM, 'ok', moduleScope(m), 'Tous les noms d’entités sont en PascalCase.'), ], } }, }, { id: 'DM-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const bad: string[] = [] for (const e of m.entities) { for (const a of e.attributes) { if (!/^[a-zA-Z][A-Za-z0-9]*$/.test(a.name)) bad.push(`${e.name}.${a.name}`) // The corpus writes attribute tables in PascalCase (doc-templates) — // the camelCase convention concerns the MCD notation. Accept both // Pascal and camel; flag underscores/hyphens/digit-first only. } } // Rows the parser REFUSED are this rule's subject too. For as long as the // parser dropped them silently, this rule could only ever fire on an // underscore — accents, hyphens, digit-first never reached it, and its // ok verdict described the survivors, not the document. for (const w of m.entityWarnings) if (isAttributeRowLoss(w)) bad.push(w) return { findings: [ bad.length > 0 ? finding('DM-007', DIM, 'warn', moduleScope(m), 'Attribut(s) hors convention (lettres/chiffres, initiale alphabétique).', bad) : finding('DM-007', DIM, 'ok', moduleScope(m), 'Tous les attributs respectent la convention de nommage.'), ], } }, }, { id: 'DM-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const bad = m.entities .filter((e) => !e.tablePrefix || !/^[a-z]{2,5}_$/.test(e.tablePrefix)) .map((e) => `${e.name}${e.tablePrefix ? ` (« ${e.tablePrefix} »)` : ' (absent)'}`) return { findings: [ bad.length > 0 ? finding('DM-008', DIM, 'warn', moduleScope(m), 'Préfixe de table absent ou invalide (attendu : 2-5 minuscules + underscore).', bad) : finding('DM-008', DIM, 'ok', moduleScope(m), 'Tous les préfixes de table sont valides.'), ], } }, }, { id: 'DM-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const evidence: string[] = [] for (const e of m.entities) { if (isLookup(e) || isTechnical(e)) continue const outgoing = e.relations.length const incoming = incomingCount(m, e, model) if (outgoing > 0 || incoming > 0) continue const iso = e.isolation ?? '' const reason = iso.split('—')[1]?.trim() ?? '' if (/by-design/i.test(iso) && reason !== '') continue evidence.push(`${e.name}${iso ? ' (marqueur présent mais raison vide)' : ' (aucun marqueur Isolation: by-design)'}`) } return { findings: [ evidence.length > 0 ? finding('DM-009', DIM, 'warn', moduleScope(m), 'Entité(s) isolée(s) sans justification — lier, reclasser technical, ou ajouter `Isolation: by-design — `.', evidence) : finding('DM-009', DIM, 'ok', moduleScope(m), 'Aucune entité isolée injustifiée.'), ], } }, }, { id: 'DM-010', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! if (m.rules.length === 0) { return { findings: [finding('DM-010', DIM, 'ok', moduleScope(m), 'Aucune règle métier en portée — vérification sans objet.')] } } const known = new Set() for (const e of model.graph.entities) { known.add(fold(e.name)) known.add(fold(e.code)) } const attrs = new Set() for (const e of m.entities) for (const a of e.attributes) attrs.add(fold(pascalOf(a.name))) const evidence: string[] = [] for (const r of m.rules) { const expr = r.expression ?? '' if (!expr) continue const unknown = pascalTokens(expr).filter( (t) => !PRIMITIVE_TOKENS.has(t) && !known.has(fold(t)) && !attrs.has(fold(t)), ) if (unknown.length > 0) evidence.push(`${r.code} : ${[...new Set(unknown)].join(', ')}`) } return { findings: [ evidence.length > 0 ? finding('DM-010', DIM, 'warn', moduleScope(m), 'Expression(s) de règle référençant des entités inconnues du MCD.', evidence) : finding('DM-010', DIM, 'ok', moduleScope(m), `Expressions des ${m.rules.length} règle(s) vérifiées contre le MCD.`), ], } }, }, { id: 'DM-011', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! if (m.ucs.length === 0 && m.rules.length === 0 && m.sections.length === 0) { return { findings: [finding('DM-011', DIM, 'ok', moduleScope(m), 'Phases amont vides — traçabilité sans objet.')] } } const upstream: string[] = [] for (const uc of m.ucs) { upstream.push(uc.title, ...uc.mainFlow, ...uc.preconditions, ...uc.postconditions) upstream.push(...uc.alternatives.map((b) => b.text), ...uc.exceptions.map((b) => b.text)) for (const ac of uc.acs) upstream.push(ac.text) } for (const r of m.rules) upstream.push(r.title, r.condition ?? '', r.expression ?? '') const upstreamFolded = fold(upstream.join('\n')) const ucCodes = new Set(m.ucs.map((u) => u.ucCode.toUpperCase())) const brCodes = new Set(m.rules.map((r) => r.code.toUpperCase())) const sectionFolders = new Set(m.sections.map((s) => fold(s))) const tracedDirect = (e: BaEntity): boolean => { // (a) Traçabilité refs that resolve to existing UC/BR codes. for (const ref of e.traceability) { const token = ref.toUpperCase().match(/(UC|BR)-[A-Z0-9_-]+/)?.[0] if (token && (ucCodes.has(token) || brCodes.has(token))) return true } // (b) verbatim name/code in the upstream text. const nameRe = new RegExp(`\\b${fold(e.name)}\\b`) if (nameRe.test(upstreamFolded) || upstreamFolded.includes(fold(e.code))) return true // (c) menu resource/section named after the entity (kebab, ±s). const kebab = fold(e.name).replace(/([a-z0-9])([A-Z])/g, '$1-$2') if (sectionFolders.has(kebab) || sectionFolders.has(`${kebab}s`) || sectionFolders.has(kebab.replace(/s$/, ''))) return true return false } const traced = new Set(m.entities.filter(tracedDirect).map((e) => e.name)) // Junction inheritance: traced when every relation target is traced. let grew = true while (grew) { grew = false for (const e of m.entities) { if (traced.has(e.name) || e.relations.length === 0) continue if (e.relations.every((r) => traced.has(r.targetEntity) || !m.entities.some((x) => x.name === r.targetEntity))) { traced.add(e.name) grew = true } } } const untraced = m.entities.filter((e) => !traced.has(e.name)).map((e) => e.name) return { findings: [ untraced.length > 0 ? finding( 'DM-011', DIM, 'warn', moduleScope(m), 'Entité(s) sans ancre amont (UC/BR/ressource) — ajouter une référence verbatim en Traçabilité ou supprimer l’entité (garde anti-hallucination).', untraced, ) : finding('DM-011', DIM, 'ok', moduleScope(m), `${traced.size} entité(s) tracée(s) par l'amont.`), ], } }, }, { id: 'DM-012', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const lookups = m.entities.filter(isLookup) if (lookups.length === 0) { return { findings: [finding('DM-012', DIM, 'ok', moduleScope(m), 'Aucune entité lookup en portée.')] } } // Only a SANCTIONED code is asked to carry its unique index. On a lookup // DM-022 errs on, proposing to add `(code) unique` would recommend // hardening the very column being removed — two contradictory remedies on // one line. The shared verdict arbitrates, so they cannot diverge. const sanctioned = (e: BaEntity): boolean => !isReferentialCodeError(classifyReferentialCode(e, []).status) const missing = lookups .filter((e) => attrByFoldedName(e, 'code')) .filter(sanctioned) .filter((e) => !e.indexes.some((i) => i.unique && fold(i.fields[0] ?? '') === 'code')) .map((e) => e.name) return { findings: [ missing.length > 0 ? finding('DM-012', DIM, 'warn', moduleScope(m), 'Lookup(s) avec attribut `code` sans index unique sur `code` — ajouter `(code) unique` (jamais TenantId : le scaffolder ajoute la branche tenant lui-même).', missing) : finding('DM-012', DIM, 'ok', moduleScope(m), `${lookups.length} lookup(s) vérifié(s).`), ], } }, }, { id: 'DM-013', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const offending: string[] = [] // Structured twin of `offending` — the router reads THIS, not the // French string, to name the entity/attribute it must hand to the // authoring skill. const anchors: FindingAnchor[] = [] for (const e of m.entities) { for (const a of e.attributes) { if (!/guid/i.test(a.type)) continue if (!/Id$/i.test(a.name) || fold(a.name) === 'id') continue if (fold(a.name) === 'tenantid') continue // realized from tenant mode if (isAuditAllowlisted(pascalOf(a.name))) continue const pascal = fold(pascalOf(a.name)) const stem = fold(a.name.replace(/Id$/i, '')) const declared = e.relations.some( (r) => fold(r.fk) === pascal || fold(r.targetEntity) === stem, ) if (!declared) { offending.push(`${e.name}.${a.name}`) anchors.push({ kind: 'attribute', id: e.name, attribute: a.name }) } } } return { findings: [ offending.length > 0 ? finding( 'DM-013', DIM, 'err', moduleScope(m), 'FK implicite(s) : attribut *Id:guid sans relation déclarée — toute référence inter-tables est une FK (ajouter l’entrée Relations, quel que soit le scope).', offending, undefined, { anchors }, ) : finding('DM-013', DIM, 'ok', moduleScope(m), 'Chaque *Id:guid est une relation déclarée (ou colonne allowlistée).'), ], } }, }, { id: 'DM-014', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const lookups = m.entities.filter(isLookup) if (lookups.length === 0) { return { findings: [finding('DM-014', DIM, 'ok', moduleScope(m), 'Aucune entité lookup en portée.')] } } const dead: string[] = [] for (const L of lookups) { const viaRelation = model.graph.relations.some((r) => r.targetEntity === L.name || r.targetEntity === L.code) const fkName = fold(`${camelOf(L.name)}Id`) const viaAttr = model.graph.entities.some((e) => e.attributes.some((a) => fold(a.name) === fkName)) if (!viaRelation && !viaAttr) dead.push(`${L.name} (${L.code})`) } return { findings: [ dead.length > 0 ? finding('DM-014', DIM, 'warn', moduleScope(m), 'Lookup(s) référencé(s) par aucune entité — déclarer la relation N:1, l’attribut FK, ou supprimer.', dead) : finding('DM-014', DIM, 'ok', moduleScope(m), `${lookups.length} lookup(s) référencé(s).`), ], } }, }, { id: 'DM-015', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const evidence: string[] = [] for (const e of m.entities) { if (e.attributes.length === 0) continue // DM-002 owns empties const missing: string[] = [] if (isLookup(e)) { // NO `code` leg: a reference value does not carry one — its label is // its identity. This rule states a MISSING field; the PRESENCE of a // code on a reference table is DM-022 business, never this one. if (!attrByFoldedName(e, 'label') && !attrByFoldedName(e, 'name')) missing.push('label|name') if (!attrByFoldedName(e, 'isActive')) missing.push('isActive') } else if (isTechnical(e)) { if (!e.isolation) missing.push('Isolation: by-design') } else if (isComponent(e)) { const hasFk = e.relations.length > 0 || e.attributes.some((a) => /Id$/i.test(a.name) && fold(a.name) !== 'id' && /guid/i.test(a.type)) if (!hasFk) missing.push('FK parent') } else { const hasIdentifier = e.attributes.some((a) => ['name', 'label', 'code', 'nom', 'libelle', 'titre', 'title'].includes(fold(a.name))) if (!hasIdentifier) { // §26 display corollary: absence is legitimate ONLY when persisted // as **Personne** or an explicit **Affichage** line. const displayFamily = new Set(PREFERRED_DISPLAY_FIELDS.map((f) => fold(f))) const hasDisplayFamily = e.attributes.some((a) => displayFamily.has(fold(a.name))) if (!hasDisplayFamily && !e.person && !e.display) missing.push('identifiant métier (name|label) ou **Affichage**/**Personne**') } } if (missing.length > 0) evidence.push(`${e.name}: ${missing.join(', ')}`) } return { findings: [ evidence.length > 0 ? finding('DM-015', DIM, 'warn', moduleScope(m), 'Conventions d’attributs par classification non respectées (scaffolds minces / displayName sans fallback sanctionné).', evidence) : finding('DM-015', DIM, 'ok', moduleScope(m), 'Conventions de classification respectées.'), ], } }, }, { id: 'DM-016', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const evidence: string[] = [] let checked = 0 for (const e of m.entities) { const ownAttrs = new Set(e.attributes.map((a) => fold(pascalOf(a.name)))) for (const a of e.attributes) { if (!a.computed) continue checked++ if (a.computed.length > 1000) evidence.push(`${e.name}.${a.name} : formule > 1000 caractères`) const foreign = pascalTokens(a.computed).filter((t) => !PRIMITIVE_TOKENS.has(t) && !ownAttrs.has(fold(t))) for (const t of [...new Set(foreign)]) evidence.push(`${e.name}.${a.name} → ${t}`) } } return { findings: [ evidence.length > 0 ? finding('DM-016', DIM, 'err', moduleScope(m), 'Formule(s) Calculé référençant des propriétés hors entité (JOIN impossible en projection LINQ) — dénormaliser ou déplacer côté service.', evidence) : finding('DM-016', DIM, 'ok', moduleScope(m), `${checked} formule(s) Calculé vérifiée(s).`), ], } }, }, { id: 'DM-017', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const errs: string[] = [] const warns: string[] = [] const numberingRules = m.rules.filter((r) => r.type === 'numbering') const entityByFoldedName = new Map(m.entities.map((e) => [fold(e.name), e])) // Checks 1-4 — per coded entity. for (const e of m.entities) { if (!e.codePatternRaw) continue const cp = e.codePattern // 1. a numbering rule names the entity, or Traçabilité carries its BR. const ruleBrCodes = new Set(numberingRules.map((r) => r.code.toUpperCase())) const namedByRule = numberingRules.some((r) => new RegExp(`\\b${fold(e.name)}\\b`).test(fold(`${r.title} ${r.condition ?? ''} ${r.portee ?? ''}`)), ) const tracedToRule = e.traceability.some((t) => ruleBrCodes.has(t.toUpperCase().match(/BR-[A-Z0-9_-]+/)?.[0] ?? '')) if (!namedByRule && !tracedToRule) warns.push(`${e.name}:missing-numbering-rule`) // 2. scope tenant|global only. if (/parent\s*:/i.test(e.codePatternRaw)) errs.push(`${e.name}:unsupported-scope`) // 3. format tokens (closed grammar, {SEQ} or a derived token required). if (!cp || cp.format === null) { errs.push(`${e.name}:no-format-mask`) } else { const formatErrors = validateFormat(cp.format, cp.reset ?? 'None') for (const err of formatErrors) errs.push(`${e.name}:${err}`) // 4. reset ↔ date-token alignment. const tokens = new Set(tokenNames(cp.format)) if (cp.reset === 'Yearly' && !tokens.has('YY') && !tokens.has('YYYY')) warns.push(`${e.name}:reset-misaligned (Yearly sans {YY}/{YYYY})`) if (cp.reset === 'Monthly' && !tokens.has('MM')) warns.push(`${e.name}:reset-misaligned (Monthly sans {MM})`) if (cp.reset === 'Daily' && !tokens.has('DD')) warns.push(`${e.name}:reset-misaligned (Daily sans {DD})`) } } // Check 5 — MODULE-WIDE: no re-modelled allocator. for (const e of m.entities) { const nameHit = ALLOC_NAME_RE.test(e.name) const attrHit = e.attributes.some((a) => matchCapabilityAttribute(a.name)?.key === 'code-generation') if (nameHit || attrHit) errs.push(`allocator:remodelled — ${e.name} (le socle alloue sur core.seq_Sequences ; déclarer **Code pattern**, jamais une entité compteur)`) } // Check 7 — supplied + {SEQ}: the sequential allocator never consults // the uniqueness probe before allocating, so a manually supplied code // inside the sequence's FUTURE corridor only fails LATER, at the unique // index, when the sequence reaches it. Warn — the import/reprise case // is legitimate and the index stays the last-resort net (as at the // socle); the remedy is shape separation, never a ban. for (const e of m.entities) { const cp = e.codePattern if (cp?.supplied === true && cp.format !== null && hasSequence(cp.format)) { warns.push( `${e.name}:supplied-sequential-corridor — codes fournis HORS de la forme du masque ` + '(préfixe différent), réservés aux imports/reprises, ou format dérivé sans {SEQ}', ) } } // Check 6 — MODULE-WIDE: every numbering rule lands on a coded entity. for (const r of numberingRules) { const text = fold(`${r.title} ${r.condition ?? ''} ${r.portee ?? ''}`) const target = [...entityByFoldedName.keys()].find((n) => new RegExp(`\\b${n}\\b`).test(text)) if (!target) { warns.push(`${r.code}:rule:unmappable`) } else if (!entityByFoldedName.get(target)!.codePatternRaw) { errs.push(`${r.code}:rule:no-code-pattern (entité ${entityByFoldedName.get(target)!.name} sans ligne **Code pattern** — la règle est invisible de toute la chaîne aval)`) } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('DM-017', DIM, 'err', moduleScope(m), 'Code patterns : token inconnu / masque absent / allocateur re-modélisé / règle numbering sans **Code pattern**.', errs)) if (warns.length > 0) findings.push(finding('DM-017', DIM, 'warn', moduleScope(m), 'Code patterns incomplets (règle numbering manquante, reset mal aligné, règle non mappable).', warns)) if (findings.length === 0) findings.push(finding('DM-017', DIM, 'ok', moduleScope(m), 'Code patterns cohérents (grammaire fermée, aucune ré-implémentation d’allocateur).')) return { findings } }, }, { id: 'DM-018', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const errs: string[] = [] const warns: string[] = [] for (const e of m.entities) { // (a) whitelist collision. const core = matchCoreEntity(e.name) if (core) errs.push(`${e.name} → Core ${core.name} (${core.qualifiedTable})`) // (b) reserved collision. const reserved = matchReservedCoreName(e.name) if (reserved) errs.push(`${e.name} → réservé Core ${reserved.name} — utiliser ${reserved.useInstead}`) // (c) unlinked person cluster (fail-closed). const identityCount = PERSON_IDENTITY_ATTRIBUTES.filter((n) => attrByFoldedName(e, n)).length const personSignal = matchPersonTrigger(e.name) !== undefined || identityCount >= 2 const userRelation = e.relations.some((r) => r.scope === 'core' && fold(r.targetEntity) === 'user') const personLine = e.person ?? null if (personSignal && !core && !reserved) { if (personLine === null && !userRelation) { errs.push(`${e.name} : cluster personne sans ligne **Personne** ni relation *→1 User scope core (annuaire décorrélé d'auth_Users)`) } else if (personLine !== null && /^none\b/i.test(personLine.trim())) { if (!/d[eé]cision client/i.test(personLine)) warns.push(`${e.name} : **Personne** : none sans « décision client : »`) } else if (personLine !== null && !userRelation) { errs.push(`${e.name} : **Personne** déclarée mais relation *→1 User — FK UserId, scope core absente`) } } // (d) mandatory person redeclares identity. if (personLine !== null && /^mandatory\b/i.test(personLine.trim()) && identityCount > 0) { const redeclared = PERSON_IDENTITY_ATTRIBUTES.filter((n) => attrByFoldedName(e, n)) errs.push(`${e.name} : Personne mandatory mais identité redéclarée localement (${redeclared.join(', ')}) — l'identité vient d'auth_Users`) } // (e) company-identity cluster. if (!core) { const companyHits = COMPANY_IDENTITY_ATTRIBUTES.filter((n) => attrByFoldedName(e, n)) const orgRelation = e.relations.some((r) => r.scope === 'core' && fold(r.targetEntity) === 'tenantorganisation') if (companyHits.length >= 1 && !orgRelation) { warns.push(`${e.name} : attributs d'identité société (${companyHits.join(', ')}) sans relation *→1 TenantOrganisation scope core`) } } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('DM-018', DIM, 'err', moduleScope(m), 'Collision avec le catalogue Core / cluster personne non lié — référencer Core (`scope core`), jamais le recréer.', errs)) if (warns.length > 0) findings.push(finding('DM-018', DIM, 'warn', moduleScope(m), 'Overrides non tracés / cluster société sans référence TenantOrganisation.', warns)) if (findings.length === 0) findings.push(finding('DM-018', DIM, 'ok', moduleScope(m), 'Aucune duplication du catalogue Core ; clusters personne/société correctement liés.')) return { findings } }, }, { id: 'DM-019', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const errs: string[] = [] const warns: string[] = [] for (const e of m.entities) { // (a) binary content in ANY entity. for (const a of e.attributes) { const byType = matchCapabilityAttribute(a.type) const byName = matchCapabilityAttribute(a.name) if (byType?.key === 'file-storage' || byName?.key === 'file-storage') { errs.push(`${e.name}.${a.name} (${a.type}) → contenu binaire en base — les octets passent par IFileStorageService`) } } // (b) attachment-named entity without the metadata shape. const trigger = matchCapabilityTrigger(e.name) if (trigger?.key === 'file-storage') { const hasStoredName = e.attributes.some((a) => /stored/i.test(a.name)) const hasContentType = e.attributes.some((a) => /contenttype/i.test(a.name)) const hasSize = e.attributes.some((a) => /size|taille/i.test(a.name)) const hasParentFk = e.relations.length > 0 || e.attributes.some((a) => /Id$/i.test(a.name) && fold(a.name) !== 'id' && /guid/i.test(a.type)) if (!(hasStoredName && hasContentType && hasSize && hasParentFk)) { warns.push(`${e.name} : entité pièce-jointe sans le shape métadonnées complet (StoredFileName, ContentType, taille, FK parent)`) } } } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('DM-019', DIM, 'err', moduleScope(m), 'Contenu de fichier dans le MCD — jamais une colonne binaire.', errs)) if (warns.length > 0) findings.push(finding('DM-019', DIM, 'warn', moduleScope(m), 'Entité(s) pièce jointe hors pattern métadonnées.', warns)) if (findings.length === 0) findings.push(finding('DM-019', DIM, 'ok', moduleScope(m), 'Aucun contenu binaire ; entités pièce jointe conformes au pattern métadonnées.')) return { findings } }, }, { id: 'DM-020', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const evidence: string[] = [] let checked = 0 for (const e of m.entities) { const declarations = [ ...e.derivedRaw, ...e.attributes.filter((a) => /d[eé]riv[eé]/i.test(a.constraints)).map((a) => `${a.name} — ${a.constraints}`), ] for (const decl of declarations) { checked++ const nav = /nav\s+([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)/.exec(decl) const child = /child\s+([A-Za-z0-9_]+)[\s\S]*?(?:latest\(([A-Za-z0-9_]+)\)|open(?:-period)?\(([A-Za-z0-9_]+)\))[\s\S]*?select\s+([A-Za-z0-9_]+)/.exec(decl) if (nav) { const navName = nav[1]! const resolved = e.relations.some( (r) => fold(r.targetEntity) === fold(navName) || fold(r.fk) === fold(`${navName}Id`), ) if (!resolved) evidence.push(`${e.name} : nav ${navName}.${nav[2]} — aucune Relation/FK ${navName}Id sur l'entité`) } else if (child) { const collection = child[1]! const pickField = child[2] ?? child[3]! const selectProp = child[4]! const childEntity = m.entities.find( (x) => x.relations.some((r) => r.targetEntity === e.name && (r.cardinality === '*→1' || r.cardinality === '1→1')) && (fold(x.name) === fold(collection) || fold(`${x.name}s`) === fold(collection)), ) if (!childEntity) { evidence.push(`${e.name} : child ${collection} — aucune relation 1→* vers une entité enfant de ce nom`) } else { if (!attrByFoldedName(childEntity, pickField)) evidence.push(`${e.name} : child ${collection} — champ de pick « ${pickField} » absent de ${childEntity.name}`) if (!attrByFoldedName(childEntity, selectProp)) evidence.push(`${e.name} : child ${collection} — propriété sélectionnée « ${selectProp} » absente de ${childEntity.name}`) } } else { evidence.push(`${e.name} : déclaration **Dérivé** non analysable « ${decl.slice(0, 80)} » (attendu \`nav