/** * lib/ba-test-data-check.ts — THE deterministic check of a module's business * TEST DATASET (`jeu-de-test.md`, lib/ba-test-data) against the MCD, the rules, * the actors and the OTHER modules' datasets — and its normalization into the * JSON `scaffold-seed` consumes as `testData[]`. * * ONE engine, two readers: `create-test-data/cli/derive-test-data` (the CLI, * modes check/derive) and audit-ba DM-030..032 (the verdict). It lives in * lib/ because audit-ba may only import cross-skill from the installer's * ENUMERATED allowlist (create-rbac, create-screen) — a `create-test-data` * import would deploy broken. PURE — zero `node:fs`. * * Reference rules (business-analyse/_workflow/doc-templates.md, « Références * entre modules ») — the cell of a FK column is resolved WHERE THE RELATION * SAYS THE TARGET LIVES (`scope same-module | cross-module (APP/MOD) | core`): * - same-module / cross-module → the target module's dataset (by key, else * by display value), or the target entity's `**Valeurs initiales**`; * - core `User` → an ACTOR of acteur.md (code or label) — at seed time the * actor's role names the test user the module already seeds; * - other core targets → a literal, resolved by `Name` at seed time (the * socle seeds its demo Core rows AFTER the client providers, so the seed * retries at every startup). * Nothing is copied across modules; nothing is ever resolved to a Guid here. */ import type { BaEntity, BaEntityAttribute } from './ba-entities.js' import { enumValuesOf } from './ba-list-split.js' import type { BaRule } from './ba-rules-rows.js' import type { BaActor } from './ba-actors.js' import { normalizeModulePath, type EntityRelation, type RelationGraph } from './ba-relations.js' import { preferredDisplayFieldsFor } from './display-field.js' import { dataColumnIndexes, findTestDataSet, type BaTestDataDoc, type BaTestDataSet, } from './ba-test-data.js' // --------------------------------------------------------------------------- // Report shapes (shared by the CLI and the audit) // --------------------------------------------------------------------------- export type IssueSeverity = 'err' | 'warn' export type IssueCode = | 'unknown-entity' | 'duplicate-initial-values' | 'missing-key' | 'key-not-a-column' | 'duplicate-key' | 'unknown-column' | 'id-column' | 'computed-column' | 'required-missing' | 'required-empty' | 'enum-value' | 'unique-duplicate' | 'code-allocated' | 'date-format' | 'number-format' | 'boolean-format' | 'fk-unresolved' | 'fk-module-absent' | 'actor-unknown' | 'cycle' | 'module-cycle' | 'flow-status-missing' | 'reference-date-missing' | 'reference-date-mismatch' | 'row-count' | 'core-unsupported' export interface TestDataIssue { severity: IssueSeverity code: IssueCode /** `JT-001` — absent for document-level issues. */ set?: string entity?: string /** 1-based data row index (header excluded). */ row?: number column?: string message: string } /** A row reference — the owner module, the entity, its key field and the key VALUE. */ export interface RowRef { ref: string entity: string keyField: string key: string /** The TARGET entity's Portée — a global (`none`) target is looked up without a tenant clause, whatever the citing entity's. */ tenantMode: 'tenant' | 'none' } export interface ActorRef { actor: string label: string } export interface CoreRef { core: string by: string value: string } export type DerivedValue = string | number | boolean | null | RowRef | ActorRef | CoreRef export interface DerivedSet { code: string entity: string entityCode: string | null keyField: string tenantMode: 'tenant' | 'none' /** Topological index inside the module (0 = no same-module dependency). */ order: number /** Property → C# type for the scalars the BA type names unambiguously. */ types: Record /** Attributes whose C# type ONLY Phase 1's `fields[]` map knows (enums). */ needsTypes: string[] /** `APP/MOD/Entity` blocks this one cites. */ dependsOn: string[] rows: Record[] } export interface DerivedTestData { module: string referenceDate: string | null /** Topological rank of the module among the modules its dataset cites (0 = cites none). */ rank: number /** `APP/MOD` modules this dataset depends on, in rank order. */ moduleDependencies: string[] sets: DerivedSet[] } export interface SetSummary { code: string entity: string keyField: string | null rows: number issues: number } export interface DeriveTestDataReport { mode: 'check' | 'derive' app: string module: string status: 'absent' | 'ok' | 'issues' file: string | null referenceDate: string | null sets: SetSummary[] issues: TestDataIssue[] totals: { sets: number rows: number err: number warn: number unresolvedRefs: number } /** What the check READ — an empty issue list must never mean « nothing was there to read ». */ coverage: { modulesRead: string[] testDataDocs: number rules: number actors: number } scopeNotes: string[] derived: DerivedTestData | null warnings: string[] } export interface ModuleInputs { /** `APP/MOD` as on disk. */ modulePath: string entities: BaEntity[] testData: BaTestDataDoc | null } export interface DeriveTestDataContext { app: string module: string mode: 'check' | 'derive' /** Every module of the project that has an entité.md, keyed by NORMALIZED `APP/MOD`. */ modules: Map graph: RelationGraph /** Rules of THIS module (module + sections). */ rules: BaRule[] /** Actors of THIS application. */ actors: BaActor[] /** Path of the dataset file when it exists (for the report). */ file: string | null warnings: string[] } // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- const ci = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() /** Core targets the generated seed provider resolves in v1 (User is resolved by ACTOR, separately). */ export const SUPPORTED_CORE_SEED_TARGETS: readonly string[] = ['TenantOrganisation'] /** `User` / `Users` / `auth_Users` — the Core identity entity. */ function isCoreUser(relation: EntityRelation): boolean { return /^users?$/i.test(relation.targetEntity) || /auth_Users/i.test(relation.scopeDetail ?? '') } function isRequired(attr: BaEntityAttribute): boolean { return /\brequis\b|\brequired\b/i.test(attr.constraints) } function isPrimaryKey(attr: BaEntityAttribute): boolean { return /\bPK\b/i.test(attr.constraints) || ci(attr.name, 'Id') } function isUnique(attr: BaEntityAttribute): boolean { return /\bunique\b/i.test(attr.constraints) } /** BA type → C# type for the scalars the BA vocabulary names unambiguously; null = Phase 1's `fields[]` must say. */ export function csTypeOf(baType: string): string | null { const t = baType.trim().toLowerCase() if (/^(string|text|textarea|varchar|nvarchar)/.test(t)) return 'string' if (/^(int|integer|number)\b/.test(t)) return 'int' if (/^long\b/.test(t)) return 'long' if (/^(bool|boolean)\b/.test(t)) return 'bool' if (/^decimal/.test(t) || /^(money|currency|montant)/.test(t)) return 'decimal' if (/^double/.test(t)) return 'double' if (/^float/.test(t)) return 'float' if (/^datetime/.test(t)) return 'DateTime' if (/^date\b/.test(t)) return 'DateOnly' if (/^time\b/.test(t)) return 'TimeOnly' if (/^(guid|uuid)\b/.test(t)) return 'Guid' return null } type ValueKind = 'string' | 'int' | 'decimal' | 'bool' | 'date' | 'datetime' | 'enum' | 'other' function valueKindOf(attr: BaEntityAttribute): ValueKind { const t = attr.type.trim().toLowerCase() if (/^enum\b/.test(t)) return 'enum' const cs = csTypeOf(attr.type) if (cs === 'string') return 'string' if (cs === 'int' || cs === 'long') return 'int' if (cs === 'decimal' || cs === 'double' || cs === 'float') return 'decimal' if (cs === 'bool') return 'bool' if (cs === 'DateOnly') return 'date' if (cs === 'DateTime') return 'datetime' return 'other' } /** `AAAA-MM-JJ` AND a real calendar date — `2024-13-01` has the shape and is still no date. */ function isIsoDate(s: string): boolean { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) if (!m) return false const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])] const dt = new Date(Date.UTC(y, mo - 1, d)) return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d } function isIsoDateTime(s: string): boolean { const m = /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(s) if (!m) return false if (!isIsoDate(m[1]!)) return false if (m[2] !== undefined && (Number(m[2]) > 23 || Number(m[3]) > 59 || (m[4] !== undefined && Number(m[4]) > 59))) return false return true } const TRUE_WORDS = new Set(['oui', 'true', 'vrai', 'yes', '1', 'x']) const FALSE_WORDS = new Set(['non', 'false', 'faux', 'no', '0', '']) /** Enum values authored in the Contraintes cell (`Actif/Archivé`, `draft/submitted, requis`). */ function enumValues(attr: BaEntityAttribute): string[] { const vals = enumValuesOf(attr.constraints).filter((v) => !/^(requis|required|unique|pk|nullable)$/i.test(v)) return vals } /** Kahn's topological sort over string nodes; returns null on a cycle (with the nodes left). */ function topoSort(nodes: string[], edges: Map>): { order: string[]; cycle: string[] } { const indeg = new Map(nodes.map((n) => [n, 0])) for (const [from, tos] of edges) { for (const to of tos) if (indeg.has(to) && from !== to) indeg.set(to, (indeg.get(to) ?? 0) + 1) } // Edges point DEPENDENCY → DEPENDENT (a cited block comes first). const ready = nodes.filter((n) => (indeg.get(n) ?? 0) === 0) const order: string[] = [] while (ready.length > 0) { const n = ready.shift()! order.push(n) for (const to of edges.get(n) ?? []) { if (!indeg.has(to) || to === n) continue indeg.set(to, (indeg.get(to) ?? 0) - 1) if (indeg.get(to) === 0) ready.push(to) } } return { order, cycle: nodes.filter((n) => !order.includes(n)) } } // --------------------------------------------------------------------------- // Column resolution // --------------------------------------------------------------------------- type ColumnBinding = | { kind: 'attribute'; attr: BaEntityAttribute } | { kind: 'fk'; relation: EntityRelation } | { kind: 'reserved' } | { kind: 'unknown' } function bindColumn(entity: BaEntity, column: string): ColumnBinding { const attr = entity.attributes.find((a) => ci(a.name, column)) if (attr) return { kind: 'attribute', attr } const relation = entity.relations.find( (r) => (r.cardinality === '*→1' || r.cardinality === '1→1') && (ci(r.fk, column) || ci(r.fk.replace(/Id$/i, ''), column) || ci(r.targetEntity, column)), ) if (relation) return { kind: 'fk', relation } return { kind: 'unknown' } } // --------------------------------------------------------------------------- // Target row lookup (same-module / cross-module) // --------------------------------------------------------------------------- interface TargetLookup { status: 'resolved' | 'module-absent' | 'entity-absent' | 'row-absent' ref?: RowRef /** The normalized target module path (for dependency + date checks). */ targetModule?: string /** True when the row came from the target's Valeurs initiales. */ fromInitialValues?: boolean } function displayColumnsOf(entity: BaEntity, columns: string[]): number[] { const preferred = [ ...(entity.display ? [entity.display] : []), ...preferredDisplayFieldsFor(entity.classification), ] const out: number[] = [] for (const p of preferred) { const i = columns.findIndex((c) => ci(c, p)) if (i >= 0 && !out.includes(i)) out.push(i) } return out } function lookupTargetRow( modules: Map, targetModule: string, targetEntityName: string, cell: string, ): TargetLookup { const norm = normalizeModulePath(targetModule) const mod = modules.get(norm) if (!mod) return { status: 'module-absent', targetModule: norm } const entity = mod.entities.find((e) => ci(e.name, targetEntityName)) if (!entity) return { status: 'entity-absent', targetModule: norm } const refPath = `${mod.modulePath}/${entity.name}` const targetTenant: 'tenant' | 'none' = entity.tenancy === 'none' ? 'none' : 'tenant' // 1. The owner's dataset — by key, then by display value. const set = mod.testData ? findTestDataSet(mod.testData, entity.name) : undefined if (set && set.keyField) { const keyIdx = set.columns.findIndex((c) => ci(c, set.keyField!)) if (keyIdx >= 0) { const byKey = set.rows.find((r) => r[keyIdx] === cell) ?? set.rows.find((r) => ci(r[keyIdx] ?? '', cell)) if (byKey) return { status: 'resolved', targetModule: norm, ref: { ref: refPath, entity: entity.name, keyField: set.keyField, key: byKey[keyIdx]!, tenantMode: targetTenant } } for (const d of displayColumnsOf(entity, set.columns)) { const byDisplay = set.rows.find((r) => ci(r[d] ?? '', cell)) if (byDisplay) return { status: 'resolved', targetModule: norm, ref: { ref: refPath, entity: entity.name, keyField: set.keyField, key: byDisplay[keyIdx]!, tenantMode: targetTenant } } } } } // 2. The target's Valeurs initiales (setup rows exist in every environment). const iv = entity.initialValues if (iv && iv.key && iv.columns.length > 0) { const keyIdx = iv.columns.findIndex((c) => ci(c, iv.key!)) if (keyIdx >= 0) { const byKey = iv.rows.find((r) => r[keyIdx] === cell) ?? iv.rows.find((r) => ci(r[keyIdx] ?? '', cell)) if (byKey) return { status: 'resolved', targetModule: norm, fromInitialValues: true, ref: { ref: refPath, entity: entity.name, keyField: iv.key, key: byKey[keyIdx]!, tenantMode: targetTenant } } for (const d of displayColumnsOf(entity, iv.columns)) { const byDisplay = iv.rows.find((r) => ci(r[d] ?? '', cell)) if (byDisplay) return { status: 'resolved', targetModule: norm, fromInitialValues: true, ref: { ref: refPath, entity: entity.name, keyField: iv.key, key: byDisplay[keyIdx]!, tenantMode: targetTenant } } } } } return { status: 'row-absent', targetModule: norm } } // --------------------------------------------------------------------------- // Core // --------------------------------------------------------------------------- export function deriveTestData(ctx: DeriveTestDataContext): DeriveTestDataReport { const issues: TestDataIssue[] = [] const scopeNotes: string[] = [] const warnings = [...ctx.warnings] const push = (severity: IssueSeverity, code: IssueCode, message: string, at: Partial = {}): void => { issues.push({ severity, code, message, ...at }) } const ownPath = normalizeModulePath(`${ctx.app}/${ctx.module}`) const own = ctx.modules.get(ownPath) const modulesRead = [...ctx.modules.values()].map((m) => m.modulePath).sort() const coverage = { modulesRead, testDataDocs: [...ctx.modules.values()].filter((m) => m.testData !== null).length, rules: ctx.rules.length, actors: ctx.actors.length, } const base = { mode: ctx.mode, app: ctx.app, module: ctx.module, file: ctx.file, coverage, scopeNotes, warnings, } const doc = own?.testData ?? null if (!own || doc === null) { scopeNotes.push( `${ctx.app}/${ctx.module} has no jeu-de-test.md — optional: author it with /ba-create-test-data when the module carries business entities.`, ) return { ...base, status: 'absent', referenceDate: null, sets: [], issues: [], totals: { sets: 0, rows: 0, err: 0, warn: 0, unresolvedRefs: 0 }, derived: null, } } warnings.push(...doc.warnings) if (doc.referenceDate === null) { push('warn', 'reference-date-missing', 'no `- **Date de référence** : AAAA-MM-JJ` — relative dates cannot be anchored.') } // Flow statuses cited by the module's workflow rules, for DM-032. const flowTokens = new Set() for (const r of ctx.rules) for (const t of r.flow) { flowTokens.add(t.from); flowTokens.add(t.to) } const derivedSets: DerivedSet[] = [] const summaries: SetSummary[] = [] /** same-module dependency edges: cited block code → citing block code. */ const edges = new Map>() const setByEntity = new Map() for (const s of doc.sets) setByEntity.set(s.entity.toLowerCase(), s) const moduleDeps = new Set() const citedModuleDates = new Map() let unresolvedRefs = 0 let totalRows = 0 for (const set of doc.sets) { const before = issues.length const at = { set: set.code, entity: set.entity } totalRows += set.rows.length const entity = own.entities.find((e) => ci(e.name, set.entity) || (set.entityCode !== null && ci(e.code, set.entityCode))) if (!entity) { push('err', 'unknown-entity', `${set.code} — « ${set.entity} » is no entity of ${ctx.app}/${ctx.module}'s entité.md: block ignored.`, at) summaries.push({ code: set.code, entity: set.entity, keyField: set.keyField, rows: set.rows.length, issues: issues.length - before }) continue } if (entity.initialValues !== null) { push('err', 'duplicate-initial-values', `${set.code} — ${entity.name} carries **Valeurs initiales** in entité.md: those ARE its rows, a test-data block duplicates them — remove the block.`, at) summaries.push({ code: set.code, entity: set.entity, keyField: set.keyField, rows: set.rows.length, issues: issues.length - before }) continue } if (set.rows.length < 3 || set.rows.length > 12) { push('warn', 'row-count', `${set.code} — ${set.rows.length} row(s): a representative dataset has 5 to 8.`, at) } // Columns. const bindings = set.columns.map((c, i) => (dataColumnIndexes(set).includes(i) ? bindColumn(entity, c) : ({ kind: 'reserved' } as ColumnBinding))) set.columns.forEach((c, i) => { const b = bindings[i]! if (b.kind === 'unknown') push('err', 'unknown-column', `${set.code} — column « ${c} » is neither an attribute nor a FK relation of ${entity.name}.`, { ...at, column: c }) if (b.kind === 'attribute' && isPrimaryKey(b.attr)) push('warn', 'id-column', `${set.code} — column « ${c} » is the primary key: ids are generated at seed, the column is ignored.`, { ...at, column: c }) if (b.kind === 'attribute' && b.attr.computed !== null) push('warn', 'computed-column', `${set.code} — column « ${c} » is computed (\`${b.attr.computed}\`): its value is ignored.`, { ...at, column: c }) }) // Key. let keyIdx = -1 if (set.keyField === null) { push('err', 'missing-key', `${set.code} — no \`- **Clé** : \`\`\` bullet: rows cannot be upserted nor cited.`, at) } else { keyIdx = set.columns.findIndex((c) => ci(c, set.keyField!)) if (keyIdx < 0) push('err', 'key-not-a-column', `${set.code} — **Clé** \`${set.keyField}\` is not a column of the table.`, { ...at, column: set.keyField }) else if (!entity.attributes.some((a) => ci(a.name, set.keyField!))) push('err', 'key-not-a-column', `${set.code} — **Clé** \`${set.keyField}\` is not an attribute of ${entity.name}.`, { ...at, column: set.keyField }) } if (keyIdx >= 0) { const seen = new Map() set.rows.forEach((r, i) => { const k = r[keyIdx] ?? '' if (k === '') push('err', 'required-empty', `${set.code} — row ${i + 1}: the key « ${set.keyField} » is empty.`, { ...at, row: i + 1, column: set.keyField! }) else if (seen.has(k)) push('err', 'duplicate-key', `${set.code} — row ${i + 1}: key « ${k} » already used by row ${seen.get(k)} — the upsert would silently skip it.`, { ...at, row: i + 1, column: set.keyField! }) else seen.set(k, i + 1) }) } // Required attributes without a column (the Create factory needs them). for (const a of entity.attributes) { if (!isRequired(a) || isPrimaryKey(a) || a.computed !== null) continue if (!set.columns.some((c) => ci(c, a.name))) push('err', 'required-missing', `${set.code} — required attribute « ${a.name} » has no column: the seed's Create(...) cannot be called.`, { ...at, column: a.name }) } // Coded entity: the engine allocates Code unless `surchargeable à la création`. const codeCol = set.columns.find((c) => ci(c, 'Code')) if (codeCol && entity.codePattern !== null && entity.codePattern.supplied !== true) { push('err', 'code-allocated', `${set.code} — column « Code » on a coded entity: the engine allocates it at insert (drop the column, or declare \`surchargeable à la création\` on the **Code pattern**).`, { ...at, column: codeCol }) } // Cells. const uniqueSeen = new Map>() const derivedRows: Record[] = [] const types: Record = {} const needsTypes = new Set() const dependsOn = new Set() set.rows.forEach((row, ri) => { const rowNo = ri + 1 const out: Record = {} set.columns.forEach((col, colIdx) => { const b = bindings[colIdx]! const cell = (row[colIdx] ?? '').trim() const where = { ...at, row: rowNo, column: col } if (b.kind === 'reserved' || b.kind === 'unknown') return if (b.kind === 'attribute') { const a = b.attr if (isPrimaryKey(a) || a.computed !== null) return if (cell === '') { if (isRequired(a)) push('err', 'required-empty', `${set.code} — row ${rowNo}: required « ${a.name} » is empty.`, where) out[a.name] = null return } if (isUnique(a)) { const m = uniqueSeen.get(colIdx) ?? new Map() if (m.has(cell)) push('err', 'unique-duplicate', `${set.code} — row ${rowNo}: « ${a.name} » is unique but « ${cell} » already appears on row ${m.get(cell)}.`, where) m.set(cell, rowNo) uniqueSeen.set(colIdx, m) } const kind = valueKindOf(a) const cs = csTypeOf(a.type) if (cs !== null) types[a.name] = cs switch (kind) { case 'enum': { const vals = enumValues(a) needsTypes.add(a.name) if (vals.length > 0 && !vals.includes(cell)) { const near = vals.find((v) => ci(v, cell)) push('err', 'enum-value', `${set.code} — row ${rowNo}: « ${cell} » is not a value of ${a.name} (${vals.join(' / ')})${near ? ` — did you mean « ${near} » (verbatim)?` : ''}.`, where) } out[a.name] = cell return } case 'int': { if (!/^-?\d+$/.test(cell)) { push('err', 'number-format', `${set.code} — row ${rowNo}: « ${cell} » is not an integer for ${a.name}.`, where); out[a.name] = cell; return } out[a.name] = Number(cell) return } case 'decimal': { const norm = cell.replace(/['’\s]/g, '').replace(',', '.') if (!/^-?\d+(\.\d+)?$/.test(norm)) { push('err', 'number-format', `${set.code} — row ${rowNo}: « ${cell} » is not a number for ${a.name}.`, where); out[a.name] = cell; return } out[a.name] = Number(norm) return } case 'bool': { const w = cell.toLowerCase() if (TRUE_WORDS.has(w)) out[a.name] = true else if (FALSE_WORDS.has(w)) out[a.name] = false else { push('err', 'boolean-format', `${set.code} — row ${rowNo}: « ${cell} » is not oui/non for ${a.name}.`, where); out[a.name] = cell } return } case 'date': { if (!isIsoDate(cell)) push('err', 'date-format', `${set.code} — row ${rowNo}: « ${cell} » is not a valid AAAA-MM-JJ date for ${a.name}.`, where) out[a.name] = cell return } case 'datetime': { if (!isIsoDateTime(cell)) push('err', 'date-format', `${set.code} — row ${rowNo}: « ${cell} » is not a valid AAAA-MM-JJ[ hh:mm] date for ${a.name}.`, where) out[a.name] = cell return } default: if (cs === null) needsTypes.add(a.name) out[a.name] = cell return } } // FK column. const rel = b.relation if (cell === '') { out[rel.fk] = null; return } if (rel.scope === 'core') { if (isCoreUser(rel)) { const actor = ctx.actors.find((x) => ci(x.code, cell) || ci(x.label, cell)) if (!actor) { push('err', 'actor-unknown', `${set.code} — row ${rowNo}: « ${cell} » is no actor of ${ctx.app}/acteur.md (a User is cited by its ACTOR — code or label).`, where) unresolvedRefs += 1 out[rel.fk] = cell return } out[rel.fk] = { actor: actor.code, label: actor.label } return } out[rel.fk] = { core: rel.targetEntity, by: 'Name', value: cell } if (!SUPPORTED_CORE_SEED_TARGETS.includes(rel.targetEntity)) { push('warn', 'core-unsupported', `${set.code} — row ${rowNo}: « ${cell} » cites Core ${rel.targetEntity} — v1 resolves TenantOrganisation (by Name) and User (by actor) at seed time only; the row is SKIPPED by the seed unless the cell is left empty.`, where) } if (!scopeNotes.some((n) => n.includes(rel.targetEntity))) { scopeNotes.push(`Core FK ${rel.targetEntity}: cells are literals resolved by Name at seed time and retried at every startup (the socle seeds its demo Core rows after the client providers).`) } return } const targetModule = rel.scope === 'same-module' ? `${ctx.app}/${ctx.module}` : (rel.scopeDetail ?? '') const found = lookupTargetRow(ctx.modules, targetModule, rel.targetEntity, cell) if (found.status === 'resolved' && found.ref) { out[rel.fk] = found.ref dependsOn.add(found.ref.ref) if (rel.scope === 'same-module' && !found.fromInitialValues) { const citedSet = setByEntity.get(rel.targetEntity.toLowerCase()) if (citedSet && citedSet.code !== set.code) { const s = edges.get(citedSet.code) ?? new Set() s.add(set.code) edges.set(citedSet.code, s) } } if (rel.scope === 'cross-module' && found.targetModule) { moduleDeps.add(found.targetModule) const other = ctx.modules.get(found.targetModule) if (other?.testData && !found.fromInitialValues) citedModuleDates.set(found.targetModule, other.testData.referenceDate) } return } unresolvedRefs += 1 out[rel.fk] = cell const ownerLabel = `${targetModule}/${rel.targetEntity}` if (found.status === 'module-absent') push('err', 'fk-module-absent', `${set.code} — row ${rowNo}: « ${cell} » cites ${ownerLabel} but module ${targetModule} has no entité.md.`, where) else if (found.status === 'entity-absent') push('err', 'fk-unresolved', `${set.code} — row ${rowNo}: « ${cell} » cites ${ownerLabel} but that entity is not in ${targetModule}'s entité.md.`, where) else push('err', 'fk-unresolved', `${set.code} — row ${rowNo}: FK not resolved: ${ownerLabel} « ${cell} » — ${targetModule} provides no such row (jeu-de-test.md key/display, or Valeurs initiales).`, where) }) derivedRows.push(out) }) // Flow statuses never represented (DM-032). if (flowTokens.size > 0) { for (const a of entity.attributes) { if (valueKindOf(a) !== 'enum') continue const vals = enumValues(a) const relevant = vals.filter((v) => flowTokens.has(v)) if (relevant.length === 0) continue const colIdx = set.columns.findIndex((c) => ci(c, a.name)) if (colIdx < 0) continue const present = new Set(set.rows.map((r) => r[colIdx] ?? '')) for (const v of relevant) { if (!present.has(v)) push('warn', 'flow-status-missing', `${set.code} — status « ${v} » of ${entity.name}.${a.name} is reached by a Flow rule but no row carries it.`, { ...at, column: a.name }) } } } derivedSets.push({ code: set.code, entity: entity.name, entityCode: entity.code, keyField: set.keyField ?? '', tenantMode: entity.tenancy === 'none' ? 'none' : 'tenant', order: 0, types, needsTypes: [...needsTypes].sort(), dependsOn: [...dependsOn].sort(), rows: derivedRows, }) summaries.push({ code: set.code, entity: set.entity, keyField: set.keyField, rows: set.rows.length, issues: issues.length - before }) } // Same-module order (a cited block first) — a cycle is refused: no insertion order exists. const { order, cycle } = topoSort(derivedSets.map((s) => s.code), edges) if (cycle.length > 0) { push('err', 'cycle', `dependency cycle between blocks ${cycle.join(' → ')}: no insertion order exists — break one FK (make it nullable and cite in one direction only).`) } derivedSets.forEach((s) => { s.order = Math.max(0, order.indexOf(s.code)) }) derivedSets.sort((a, b) => a.order - b.order) // Module rank among the modules this dataset cites — through their own citations, transitively. const rankOf = new Map() const visiting = new Set() const deps = (m: string): Set => { const mod = ctx.modules.get(m) const out = new Set() if (!mod) return out for (const r of ctx.graph.relations) { if (r.scope !== 'cross-module' || !r.scopeDetail) continue if (normalizeModulePath(r.module) !== m) continue const target = normalizeModulePath(r.scopeDetail) if (target !== m && ctx.modules.get(target)?.testData) out.add(target) } return out } let moduleCycle: string[] | null = null const rank = (m: string): number => { if (rankOf.has(m)) return rankOf.get(m)! if (visiting.has(m)) { moduleCycle = moduleCycle ?? [...visiting, m]; return 0 } visiting.add(m) let r = 0 for (const d of deps(m)) r = Math.max(r, rank(d) + 1) visiting.delete(m) rankOf.set(m, r) return r } const ownRank = moduleDeps.size === 0 ? 0 : Math.max(0, ...[...moduleDeps].map((d) => rank(d) + 1)) if (moduleCycle !== null) { push('err', 'module-cycle', `module dependency cycle ${(moduleCycle as string[]).join(' → ')}: the test datasets cite each other — no seed order exists.`) } for (const [m, date] of citedModuleDates) { if (date !== doc.referenceDate) push('warn', 'reference-date-mismatch', `module ${m} cited by this dataset has Date de référence « ${date ?? 'absente'} » — this one has « ${doc.referenceDate ?? 'absente'} » (relative dates drift).`) } const err = issues.filter((i) => i.severity === 'err').length const warn = issues.filter((i) => i.severity === 'warn').length const derived: DerivedTestData | null = ctx.mode === 'derive' ? { module: `${ctx.app}/${ctx.module}`, referenceDate: doc.referenceDate, rank: ownRank, moduleDependencies: [...moduleDeps].sort((a, b) => rank(a) - rank(b)), sets: derivedSets, } : null return { ...base, status: err + warn === 0 ? 'ok' : 'issues', referenceDate: doc.referenceDate, sets: summaries, issues, totals: { sets: doc.sets.length, rows: totalRows, err, warn, unresolvedRefs }, derived, } }