/** * cli:scaffold-seed — validate.ts */ import { ScaffoldSeedInputSchema, normalizePermissionEntry, type ScaffoldSeedInput, type TestDataValue, type ValidationResult } from './types.js' import { slugifyRoleCode } from '../../../../../lib/string-utils.js' /** Core targets the generated test-data provider knows how to resolve (v1). */ export const SUPPORTED_CORE_TARGETS: readonly string[] = ['TenantOrganisation'] const isRowRef = (v: TestDataValue): v is { ref: string; entity: string; keyField: string; key: string } => typeof v === 'object' && v !== null && 'ref' in v const isActorRef = (v: TestDataValue): v is { actor: string; label: string } => typeof v === 'object' && v !== null && 'actor' in v const isCoreRef = (v: TestDataValue): v is { core: string; by: string; value: string } => typeof v === 'object' && v !== null && 'core' in v /** * Test dataset (jeu-de-test.md) rules — the seed half of the reference * contract: a key per row, unique; a same-spec citation must not form a cycle * (no insertion order would exist); a Core target the generator cannot resolve * is refused up front (a silent `Guid.Empty` is the failure this closes); an * actor without a seeded test user for its role is said (the lookup by email * would skip every row citing it). */ export function validateTestData(spec: ScaffoldSeedInput): { errors: string[]; warnings: string[] } { const errors: string[] = [] const warnings: string[] = [] const known = new Set([...spec.testData.map(e => e.entity), ...spec.referenceData.map(e => e.entity)]) const testUserRoles = new Set(spec.testUsers.map(tu => tu.roleCode)) const edges = new Map>() // cited → citing (within testData) const testEntities = new Set(spec.testData.map(e => e.entity)) /** One warning per (citing entity → cited entity), never one per cell. */ const foreignCitations = new Set() for (const entry of spec.testData) { const seenKeys = new Set() for (const [i, row] of entry.rows.entries()) { const keyValue = row[entry.keyField] if (keyValue === undefined || keyValue === null || keyValue === '' || typeof keyValue === 'object') { errors.push(`testData[${entry.entity}].rows[${i}] is missing its natural key "${entry.keyField}" (a scalar) — the upsert has nothing to match on.`) continue } const lit = String(keyValue) if (seenKeys.has(lit)) errors.push(`testData[${entry.entity}] declares the key "${lit}" twice — duplicate rows would be silently skipped.`) seenKeys.add(lit) for (const [prop, value] of Object.entries(row)) { if (isRowRef(value)) { if (testEntities.has(value.entity) && value.entity !== entry.entity) { const s = edges.get(value.entity) ?? new Set() s.add(entry.entity) edges.set(value.entity, s) } else if (!known.has(value.entity)) { const k = `${entry.entity}→${value.ref}` if (!foreignCitations.has(k)) { foreignCitations.add(k) warnings.push( `testData[${entry.entity}].${prop} cites ${value.ref} (e.g. « ${value.key} ») which this spec does not seed — resolved at seed time against rows another provider owns (another module's dataset, or Valeurs initiales); a row is skipped and logged when its reference is absent.`, ) } } } else if (isActorRef(value)) { const role = slugifyRoleCode(value.label) if (!testUserRoles.has(role)) { warnings.push( `testData[${entry.entity}].rows[${i}].${prop} cites actor ${value.actor} (« ${value.label} » → role "${role}") but this spec seeds no test user for that role — the lookup by email "${role}.test@${spec.testUserEmailDomain}" skips the row unless the module's TestUserSeedDataProvider seeds it.`, ) } } else if (isCoreRef(value)) { if (!SUPPORTED_CORE_TARGETS.includes(value.core)) { errors.push( `testData[${entry.entity}].rows[${i}].${prop} cites Core ${value.core} — v1 resolves ${SUPPORTED_CORE_TARGETS.join(', ')} (by Name) and User (by actor) only; leave the cell empty or drop the column.`, ) } } } } // A typed enum cell becomes `.` VERBATIM — a value that is not a // C# identifier (space, dash, punctuation) cannot compile; Phase 1 names the // members it generated, the BA value must be written the same way. const badMembers = new Set() for (const row of entry.rows) { for (const [prop, value] of Object.entries(row)) { const t = entry.types[prop] if (typeof value !== 'string' || value.startsWith('cs:') || !t) continue const lower = t.replace(/\?$/, '').trim().toLowerCase() if (['string', 'guid', 'dateonly', 'datetime', 'timeonly', 'bool', 'boolean', 'int', 'integer', 'long', 'short', 'byte', 'decimal', 'double', 'float'].includes(lower)) continue if (!/^[\p{L}_][\p{L}\p{N}_]*$/u.test(value)) badMembers.add(`${prop} « ${value} »`) } } if (badMembers.size > 0) { warnings.push( `testData[${entry.entity}]: ${[...badMembers].sort().join(', ')} — emitted as an enum member of the declared type, but the value is not a C# identifier: write the member name Phase 1 generated (or use "cs:.").`, ) } // Same CS1503 blind spot as referenceData: an untyped string cell on an enum column. const untyped = new Set() for (const row of entry.rows) { for (const [prop, value] of Object.entries(row)) { if (typeof value !== 'string' || value.startsWith('cs:') || prop === entry.keyField || entry.types[prop]) continue untyped.add(prop) } } if (untyped.size > 0) { warnings.push( `testData[${entry.entity}]: ${[...untyped].sort().join(', ')} carry string values with no declared type — emitted as C# string literals. An ENUM column needs types. (derive-test-data lists them under needsTypes; the entity fields[] map has the C# type) or the seed fails to compile (CS1503).`, ) } } // Cycle among testData entries: no insertion order exists. const indeg = new Map([...testEntities].map(e => [e, 0])) for (const tos of edges.values()) for (const to of tos) indeg.set(to, (indeg.get(to) ?? 0) + 1) const ready = [...testEntities].filter(e => (indeg.get(e) ?? 0) === 0) const order: string[] = [] while (ready.length > 0) { const n = ready.shift()! order.push(n) for (const to of edges.get(n) ?? []) { indeg.set(to, (indeg.get(to) ?? 0) - 1) if (indeg.get(to) === 0) ready.push(to) } } const stuck = [...testEntities].filter(e => !order.includes(e)) if (stuck.length > 0) { errors.push(`testData: dependency cycle between ${stuck.join(' → ')} — no insertion order exists (break one FK: cite in one direction only).`) } else { // Entries must already be in dependency order (derive-test-data emits them so). const pos = new Map(spec.testData.map((e, i) => [e.entity, i])) for (const [cited, citing] of edges) { for (const c of citing) { if ((pos.get(cited) ?? 0) > (pos.get(c) ?? 0)) { errors.push(`testData: ${c} cites ${cited} but is listed before it — order the entries dependency-first (derive-test-data --mode derive does).`) } } } } return { errors, warnings } } export function validateStructure(raw: unknown): ValidationResult { const result = ScaffoldSeedInputSchema.safeParse(raw) if (result.success) return { valid: true, errors: [], warnings: [] } const errors = result.error.issues.map(i => `[${i.path.join('.')}] ${i.message}`) return { valid: false, errors, warnings: [] } } export function validateBusinessRules(spec: ScaffoldSeedInput): ValidationResult { const errors: string[] = [] const warnings: string[] = [] // Application-level entries are now seeded by scaffold-core-seed (Phase 0). // If a caller still passes one here, warn — they likely meant to call // scaffold-core-seed instead. Empty navigation is the new normal for // ref-data-only providers. const apps = spec.navigation.filter(n => n.level === 'application') if (apps.length > 0) { warnings.push( `scaffold-seed received ${apps.length} application-level navigation entry/entries — these belong to scaffold-core-seed (Phase 0). Module-level scaffold-seed should only carry section/resource overrides or reference data.`, ) } // Permissions should reference existing sections. Non-section grains (the // multi-grain floor lives in scaffold-core-seed) are not representable in // this feature-scoped provider (legacy 3-field SectionCode tuple) — warn. const sectionCodes = new Set(spec.navigation.filter(n => n.level === 'section').map(n => n.code)) for (const perm of spec.permissions) { const sectionCode = normalizePermissionEntry(perm).sectionCode if (sectionCode === undefined) { warnings.push( `Permission ${perm.path} is app/module-grain — the feature-scoped seed provider only carries section-grain rows (the multi-grain floor belongs to scaffold-core-seed); it will be skipped`, ) } else if (!sectionCodes.has(sectionCode)) { warnings.push(`Permission ${perm.path} references unknown section: ${sectionCode}`) } } // Role permissions should reference existing roles and permissions const roleCodes = new Set(spec.roles.map(r => r.code)) const permPaths = new Set(spec.permissions.map(p => p.path)) for (const rp of spec.rolePermissions) { if (!roleCodes.has(rp.roleCode)) { warnings.push(`Role-permission references unknown role: ${rp.roleCode}`) } if (!permPaths.has(rp.permissionPath)) { warnings.push(`Role-permission references unknown permission: ${rp.permissionPath}`) } } // Test users must reference existing roles. Hard error since Phase 5 needs // these to log in by role — a silent warning would manifest as 401s in tests. for (const tu of spec.testUsers) { if (!roleCodes.has(tu.roleCode)) { errors.push(`Test user references unknown role: ${tu.roleCode}. Add the role to spec.roles or fix the typo.`) } } // Reference data (Valeurs initiales) — every row must carry its natural key, // and key values must be unique per entity (the upsert match would otherwise // silently skip the duplicate). for (const entry of spec.referenceData ?? []) { const seenKeys = new Set() for (const [i, row] of entry.rows.entries()) { const keyValue = row[entry.keyField] if (keyValue === undefined || keyValue === null || keyValue === '') { errors.push(`referenceData[${entry.entity}].rows[${i}] is missing its natural key "${entry.keyField}" — the idempotent upsert has nothing to match on.`) continue } const lit = String(keyValue) if (seenKeys.has(lit)) { errors.push(`referenceData[${entry.entity}] declares the key "${lit}" twice — duplicate rows would be silently skipped.`) } seenKeys.add(lit) } if (entry.keyField === 'Code') { warnings.push( `referenceData[${entry.entity}] upserts by Code. TWO reasons that may be wrong. (1) CODED entity (entité.md **Code pattern**): its Code is engine-allocated at insert, reference rows must NOT pre-assign it. (2) REFERENCE table (lookup): it carries no code at all unless the USER decided one (entité.md **Code décidé**, audit DM-022) — its natural key is its LABEL. Use another natural key, or write the decision down.`, ) } // A non-integer with no declared C# type is emitted as a `decimal` literal // (`2.5m`) — which does NOT compile against a double/float column. Name the // guess instead of letting it surface as a .NET build error far from here. const untypedFractions = new Set() for (const row of entry.rows) { for (const [prop, value] of Object.entries(row)) { if (typeof value === 'number' && !Number.isInteger(value) && !entry.types?.[prop]) { untypedFractions.add(prop) } } } if (untypedFractions.size > 0) { warnings.push( `referenceData[${entry.entity}]: ${[...untypedFractions].sort().join(', ')} carry fractional values with no declared type — emitted as C# decimal ('2.5m'). Pass types. (from the entity's fields[]) when the column is double/float, or the generated seed will not compile.`, ) } // Same blind spot on the STRING side, and it bites harder: an untyped // enum-valued cell is emitted as `"Insurance"` against an `AlertKind` // parameter (CS1503), a .NET build error surfacing far from here. The // generator cannot know the type — only the caller's `fields[]` map can. const untypedStrings = new Set() for (const row of entry.rows) { for (const [prop, value] of Object.entries(row)) { if (typeof value !== 'string' || value.startsWith('cs:')) continue if (prop === entry.keyField || entry.types?.[prop]) continue untypedStrings.add(prop) } } if (untypedStrings.size > 0) { warnings.push( `referenceData[${entry.entity}]: ${[...untypedStrings].sort().join(', ')} carry string values with no declared type — emitted as C# string literals. If any of them is an ENUM (or Guid/date) column, pass types. so the generator writes the real member ('Insurance' + AlertKind → AlertKind.Insurance); otherwise the seed fails to compile (CS1503).`, ) } for (const [prop, csType] of Object.entries(entry.types ?? {})) { if (csType.trim() === '') { errors.push(`referenceData[${entry.entity}].types.${prop} is empty — give the C# type or drop the entry.`) } } } const td = validateTestData(spec) errors.push(...td.errors) warnings.push(...td.warnings) return { valid: errors.length === 0, errors, warnings } } export function validate(raw: unknown): ValidationResult { const structural = validateStructure(raw) if (!structural.valid) return structural // Parse (not cast) so the schema defaults are applied — business rules on the // raw shape crashed on any spec omitting a defaulted array (navigation, …). return validateBusinessRules(ScaffoldSeedInputSchema.parse(raw)) }