/** * cli:scaffold-business — validate.ts */ import { ScaffoldBusinessInputSchema, type Field, type ScaffoldBusinessInput, type ValidationResult } from './types.js' import { CORE_PROJECTABLE_FIELDS, CORE_WHITELIST_V1 } from '../../../../../lib/core-catalog.js' import { isCodedEntity, isSupplied } from '../../../../../lib/page-spec-coded-entity.js' import { MIN_SOCLE_SUPPLIED_CODE_VERSION, readSocleVersion, socleAtLeast } from '../../../../../lib/socle-version.js' export function validateStructure(raw: unknown): ValidationResult { const result = ScaffoldBusinessInputSchema.safeParse(raw) if (result.success) return { valid: true, errors: [], warnings: [] } return { valid: false, errors: result.error.issues.map(i => { const msg = `[${i.path.join('.')}] ${i.message}` // Friendly rephrase of the structural single-identifier rule — the raw // Zod regex message doesn't explain WHY traversal is forbidden. return i.path.includes('source') && i.path[i.path.length - 1] === 'property' ? `${msg} — source.property is a SINGLE identifier on the Core entity; traversal like "Department.Name" is forbidden (navigations between two whitelist entities are ignored by SmartStackExtensionDbContext). Use ICoreDataService for nested Core reads.` : msg }), warnings: [], } } /** * Coded entity: the Code column is engine-owned (CodedEntitySaveHandler) — * a `code` field in the business layer would put it on the Create/Update * DTOs and the factory call. Mirror of scaffold-entity's fields[] guard and * scaffold-component's editable-code guard; the backend service layer was * the unguarded third layer (protected only by an accidental compile error). */ export function validateCodedEntity(spec: ScaffoldBusinessInput): ValidationResult { const errors: string[] = [] if (isCodedEntity(spec.codedEntity) && spec.fields.some(f => f.name.toLowerCase() === 'code')) { errors.push( 'codedEntity: a "code" field is declared in fields[] — the Code is engine-allocated at insert and never ' + 'rides the Create/Update surface. Remove it (it reaches the read DTOs as a stored column automatically). ' + 'The ONE sanctioned exception (an OPTIONAL supplied code on create via ISuppliedCodeGuard + ApplyCode) is ' + 'SCAFFOLDED when the entité.md line declares « surchargeable à la création » (codedEntity.supplied — ' + 'derive-code-specs stamps it) — never a fields[] entry.' ) } // Fail-closed floor: the generated guard call (ISuppliedCodeGuard) does not // COMPILE against a socle older than the release that ships it — an // actionable validate error beats an opaque CS0246. if (isSupplied(spec.codedEntity)) { const socle = readSocleVersion(spec.projectPath) if (!socleAtLeast(socle, MIN_SOCLE_SUPPLIED_CODE_VERSION)) { errors.push( `codedEntity.supplied: supplied-on-create requires the socle >= ${MIN_SOCLE_SUPPLIED_CODE_VERSION} ` + `(ISuppliedCodeGuard + the /api/codes endpoints) — this project references SmartStack ` + `${socle ?? '(unreadable)'}. Run \`ss upgrade\`, or drop the « surchargeable à la création » facet ` + `from the entité.md **Code pattern** line.` ) } } return { valid: errors.length === 0, errors, warnings: [] } } export function validateBusinessRules(spec: ScaffoldBusinessInput): ValidationResult { const warnings: string[] = [] if (spec.businessRules.length === 0) { warnings.push( 'No business rules provided — validator will only have basic field validation. If the entity\'s ' + 'pagespecs carry linkedBusinessRules (derive-rule-links), derive businessRules[] per ' + 'phases-detail § "Business rules" — the DEV-API-008 untraced leg and DEV-TEST-009 will err otherwise.', ) } return { valid: true, errors: [], warnings } } /** True when the field is stored in the entity's own table (regular column). */ function isStored(f: Field): boolean { return !f.formula && (!f.source || f.source.fallbackLocal === f.name) } /** * Validate the `fields[].source` Core projections (increment 2 of the * Core-reuse feature). Fail-closed: a mis-declared projection would emit * non-compiling or non-translatable LINQ. */ export function validateCoreProjections(spec: ScaffoldBusinessInput): ValidationResult { const errors: string[] = [] const warnings: string[] = [] const fieldByName = new Map(spec.fields.map(f => [f.name.toLowerCase(), f])) for (const f of spec.fields) { if (!f.source) continue const at = `field "${f.name}"` if (f.formula) { errors.push(`${at}: "source" and "formula" are mutually exclusive — a field is either computed from local properties or projected from a Core navigation.`) continue } const target = f.source.target ?? f.source.nav if (!CORE_WHITELIST_V1.has(target)) { errors.push( `${at}: source target "${target}" is not in the V1 Core whitelist ` + `(User, Role, Tenant, TenantOrganisation, Department, JobTitle, Office, Language, Group). ` + `Only whitelist entities carry a navigation property on extension entities. ` + `Use ICoreDataService (lookup helpers) for other Core data, or propose an addition ` + `per docs/extensions/whitelist-evolution.md.` ) continue } const fk = f.source.fkField ?? `${f.source.nav}Id` const fkField = fieldByName.get(fk.toLowerCase()) if (!fkField || fkField.type.toLowerCase() !== 'guid' || !isStored(fkField)) { errors.push( `${at}: source FK "${fk}" must be a stored guid field of this spec ` + `(the column guarding the "${f.source.nav}" navigation). Declare it in fields[] ` + `or set source.fkField to the right column.` ) continue } if (f.source.fallbackLocal) { const fallback = fieldByName.get(f.source.fallbackLocal.toLowerCase()) if (!fallback || !isStored(fallback)) { errors.push( `${at}: source.fallbackLocal "${f.source.fallbackLocal}" must name a stored field of this spec ` + `(the local column read when ${fk} is null). For the person-optional overlay, set it to the ` + `field's own name.` ) continue } } const fkNullable = fkField.required === false if (fkNullable && !f.source.fallbackLocal && f.required) { errors.push( `${at}: nullable FK "${fk}" with no fallbackLocal makes the projection nullable — ` + `set required: false on the field (DTO member becomes nullable) or provide a fallbackLocal.` ) continue } const knownProps = CORE_PROJECTABLE_FIELDS[target] if (knownProps && !knownProps.includes(f.source.property)) { warnings.push( `${at}: source property "${target}.${f.source.property}" is not in the known projectable set ` + `(${knownProps.join(', ')}) — double-check the property name (best-effort typo guard, not blocking).` ) } } return { valid: errors.length === 0, errors, warnings } } /** Derived-field coherence: one source of truth per member, and a derived * member is never `required` (its projection legitimately yields null). */ export function validateDerivedFields(spec: ScaffoldBusinessInput): ValidationResult { const errors: string[] = [] for (const f of spec.fields) { if (!f.derived) continue if (f.formula || f.source) { errors.push(`[fields.${f.name}] derived is mutually exclusive with formula/source — one derivation per member.`) } if (f.required) { errors.push(`[fields.${f.name}] a derived member cannot be required: true — its projection yields null when no source row exists (no open period, no reading yet). Author it optional.`) } } return { valid: errors.length === 0, errors, warnings: [] } } export function validate(raw: unknown): ValidationResult { const structural = validateStructure(raw) if (!structural.valid) return structural // Re-parse so Zod defaults (required: true, …) are materialized — the // nullability checks above rely on them. const spec = ScaffoldBusinessInputSchema.parse(raw) const projections = validateCoreProjections(spec) const derived = validateDerivedFields(spec) const rules = validateBusinessRules(spec) const coded = validateCodedEntity(spec) // Defence in depth behind lib/page-spec-actions' superRefine: a GET custom // action returning NoContent emits a `Task` (void) service method — the // computed value has no channel back (DEV-API-025 / PRD-124). const actionErrors: string[] = [] for (const a of spec.customActions) { // `queryParameters` is the GET transport channel (a GET carries no body) — // its presence marks the action as a read on this CLI's schema. if (a.queryParameters !== undefined && a.responseDto === 'NoContent') { actionErrors.push( `[customActions.${a.code}] a GET action MUST declare a responseDto — its service method would be ` + `Task (void): the read computes a value and throws it away. Declare the result DTO or requalify ` + `as a POST state mutation.`, ) } } return { valid: projections.valid && derived.valid && coded.valid && actionErrors.length === 0, errors: [...projections.errors, ...derived.errors, ...coded.errors, ...actionErrors], warnings: [...projections.warnings, ...rules.warnings], } }