/** * cli:scaffold-component — validate.ts */ import { ScaffoldComponentInputSchema, type ValidationResult } from './types.js' import { isCodedEntity } from '../../../../../lib/page-spec-coded-entity.js' import { parseRelatedTabs } from '../../../../../lib/page-spec-related-tabs.js' import { resolveLifecycle } from '../../../../../lib/page-spec-lifecycle.js' import { GLOBAL_SEARCH_FILTER_RE as GLOBAL_SEARCH_FILTER_FIELD } from '../../../../../lib/page-spec-filters.js' import { normalizeOffline, validatePwaMetaV1 } from '../../../../../lib/pwa-meta.js' /** camelCase the first character only — mirror of generate.ts' `fieldToCamel`. */ function toCamelFirst(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) } /** `…Id` names that are identifiers, not FK references — mirror of audit * DEV-UI-022's whitelist. Anything else needs `fkTo` or an explicit * `noLookup: true`. */ const FK_GATE_WHITELIST = new Set(['externalid', 'parentid', 'guidid']) /** A field the FK gate applies to: `…Id`-suffixed, reference-typed. */ function isFkShaped(f: { name: string; type: string }): boolean { return ( /^[A-Za-z][A-Za-z0-9]*Id$/.test(f.name) && /^(guid|uuid|string)$/i.test(f.type) && !FK_GATE_WHITELIST.has(f.name.toLowerCase()) ) } export function validate(raw: unknown): ValidationResult { const result = ScaffoldComponentInputSchema.safeParse(raw) if (result.success) { // 360 related tabs: a table/cards tab with no relatedTabsData entry still // generates (fail-open on a single createdAt column) but the caller should // know the embedded columns were not derived — hence a warning, not an error. const warnings: string[] = [] const errors: string[] = [] const spec = result.data // Legacy kanban gate — the standalone kanban page and its invocation-level // `kanbanConfig` are RETIRED: the board is a representation of the LIST // (one route, one FilterBar), configured by the first-order // `pagespec.kanban` block + `viewModes` (lib/page-spec-kanban.ts). Refuse // loudly so the orchestrator migrates the spec instead of shipping a page // that no longer exists. if (spec.views.includes('kanban') || spec.kanbanConfig !== undefined) { errors.push( `[views] the standalone 'kanban' view is retired — the board is now a viewMode of the LIST page. ` + `Author the first-order \`kanban\` block + \`viewModes: [..., "kanban"]\` on the LIST pagespec ` + `(backfill: npx tsx skills/ba-create-prd/cli/derive-kanban-spec/index.ts ` + `--spec '{"mode":"derive","pagespecDir":".smartstack/ba///pagespecs","moduleRoot":".smartstack/ba//"}'), ` + `scaffold the 'list' view, and DELETE the standalone kanban pagespec/invocation (drop kanbanConfig).`, ) } if (spec.entityViews?.includes('kanban')) { warnings.push( `[entityViews] legacy 'kanban' token ignored — the board is a viewMode of the list page, ` + `not a navigable view (no route helper exists for it).`, ) } // FK gate — a FK-shaped field with neither `fkTo` (→ ) nor // `options` (→ ) nor an explicit `noLookup: true` used to fall // back to a raw text `` where the user types a Guid, a raw-Guid list // column and a raw-Guid detail row. That silent degradation shipped (test-RH // Projet.StatutId) — it is now a validation ERROR so the orchestrator's // auto-heal loop fixes the spec (run ba-develop's derive-fk-specs) instead // of shipping the page. for (const f of spec.fields) { if (f.fkTo || f.noLookup === true || (f.options?.length ?? 0) > 0) continue if (!isFkShaped(f)) continue errors.push( `[fields.${f.name}] FK-shaped field has no fkTo — it would render as a raw Guid ` + `( on the form, unresolved column on the list, Guid
on the detail). ` + `Derive it deterministically: npx tsx skills/ba-develop/cli/derive-fk-specs/index.ts ` + `--spec '{"moduleRoot":".smartstack/ba//","entity":"${spec.entity}"}' and splice ` + `fields[].fkTo verbatim. If the field is genuinely NOT a reference, set noLookup: true.`, ) } // Coded-entity gate — the Code of an ICodedEntity is engine-allocated at // insert (CodedEntitySaveHandler); a form input on it would ship a value the // engine ignores (or, worse, a hand-typed pseudo-code). Signal-driven ONLY // (`codedEntity` flag from ba-develop Phase 3a / create-prd) — never the // field name alone: plain referentials legitimately have a user-typed code. const coded = isCodedEntity(spec.codedEntity) || isCodedEntity(spec.pageSpec?.codedEntity) if (coded && spec.views.includes('form')) { for (const f of spec.fields) { if (!/^code$/i.test(f.name)) continue if (f.readonly === true || f.isComputed === true) continue errors.push( `[fields.${f.name}] the entity is a coded entity — its Code is engine-allocated ` + `(ICodedEntity, CodedEntitySaveHandler) and NEVER a form input. Remove it from the ` + `form's fields[] (create-prd must not list it as editable) or set readonly: true ` + `to display the allocated code on edit.`, ) } } // Lifecycle gate — the first-order `lifecycle` block (lib/page-spec-lifecycle.ts) // compiles down to create-exclusion + status gates. Hard invariant: a phase- // OWNED field is NEVER `required: true` — the backend Create DTO carries the // required fields, so a required later-phase field re-creates the "invoice // create form asks the payment date" bug server-side; its column must stay // nullable and the phase-scoped requiredness lives in `requiredFields` + the // capturing action's `payloadParameters[].required`. const lc = resolveLifecycle(spec.fields, spec.pageSpec) for (const issue of lc.rejected) warnings.push(`[pageSpec.lifecycle] ${issue}`) for (const f of spec.fields) { const eff = lc.effects.get(toCamelFirst(f.name)) const explicitPhase = (f.phase ?? '').trim() !== '' const owned = explicitPhase || eff?.owned === true if (owned && f.required) { errors.push( `[fields.${f.name}] a lifecycle phase owns this field but it is required: true — a required ` + `later-phase field re-enters the backend Create DTO (required fields only) and the create ` + `contract. Make it required: false (nullable column); express the phase-scoped requiredness ` + `through lifecycle.phases[].requiredFields and the capturing action's payloadParameters[].required.`, ) } else if (eff !== undefined && !eff.owned && f.required) { warnings.push( `[fields.${f.name}] listed in lifecycle requiredFields but already required: true — the ` + `status-guarded requirement is redundant (the field is required in every phase).`, ) } } if (lc.effects.size > 0 && lc.statusField !== undefined && !lc.statusFieldOnForm) { warnings.push( `[pageSpec.lifecycle] statusField '${lc.statusField}' is not one of the form's fields — the ` + `status gates cannot read formData, every phase degrades to a bare edit-mode guard. Add the ` + `status field to fields[] (readonly / readonlyOn: 'create' is fine).`, ) } // Filter FK gate — the list-side sibling of the fields[] gate above. A // filter's FK-ness used to be re-derived at render time by exact string // equality between `filters[].field` and the entity's field names. The BA // authors a filter by its RELATION name (`department`) while the entity // carries the PROPERTY (`DepartmentId`), so the match missed and the filter // shipped as a free-text box over a Guid: no lookup, no server param, and // invisible to every audit (DEV-UI-033 only looked at `…Id` keys). // The contract is now explicit — a reference filter names the FK property // and carries `fkTo` — and this gate makes a miss loud. const filterFields = new Map(spec.fields.map(f => [toCamelFirst(f.name), f])) for (const f of spec.pageSpec?.filters ?? []) { const field = toCamelFirst(f.field) if (GLOBAL_SEARCH_FILTER_FIELD.test(field)) continue const resolved = f.fkTo ?? filterFields.get(field)?.fkTo if (resolved) continue const control = (f.control ?? 'text').toLowerCase() const target = f.entity ? ` (screen.md targets '${f.entity}')` : '' const heal = `Derive it deterministically: npx tsx skills/ba-develop/cli/derive-filter-fks/index.ts ` + `--spec '{"moduleRoot":".smartstack/ba//","entity":"${spec.entity}"}' — it rewrites the ` + `filter to the FK property name and injects fkTo.` // A reference by DECLARATION: `control: 'lookup'`, or the BA alias // `entity` the screen carries (`type: "lookup", entity: "Statut"` often // reaches the PRD as `control: "select"` + `entity`). Same signals as // derive-filter-fks and DEV-UI-033 — one definition of "reference filter". if (control === 'lookup' || (f.entity !== undefined && f.entity !== '')) { errors.push( `[pageSpec.filters.${f.field}] reference filter has no resolvable fkTo${target} — it would render ` + `as a free-text input over a Guid AND post a query param the backend does not bind. ${heal}`, ) } else if (isFkShaped({ name: f.field, type: 'string' })) { errors.push( `[pageSpec.filters.${f.field}] the filter names a FK property but no fkTo resolves for it${target} — ` + `same dead free-text filter as above. ${heal}`, ) } else if (!filterFields.has(field)) { // A filter may legitimately target a COMPUTED / projected column // (settlementStatus, origin…) that is not in fields[] — warn, never block. warnings.push( `[pageSpec.filters.${f.field}] no field of the entity matches this filter — it will filter a DTO ` + `property that may not exist. Expected either a fields[] entry or a computed column of that name.`, ) } } // Detail-tabs gate — tabs[].fields name pagespec keys (camelCase) against // fields[] carrying the entité.md casing (PascalCase in the real pipeline); // the renderer matches them camel-insensitively. An entry resolving to NO // field would silently vanish from the fiche (the historical shape: 18/18 // tabpanels emitted with an empty
, 77 declared fields, zero rendered) — // make the miss loud so auto-heal fixes the pagespec instead of shipping it. const knownFieldCamels = new Set(spec.fields.map(f => toCamelFirst(f.name))) for (const tab of (spec.pageSpec?.tabs ?? []) as Array<{ key?: string; fields?: unknown }>) { if (!Array.isArray(tab.fields)) continue for (const name of tab.fields) { if (typeof name !== 'string' || knownFieldCamels.has(toCamelFirst(name))) continue errors.push( `[pageSpec.tabs.${tab.key ?? '?'}] field '${String(name)}' resolves to none of the entity's ` + `fields[] — the detail tab would render without it. Tab fields are the camelCase keys of the ` + `entity's OWN fields; fix the pagespec tab entry or add the missing fields[] input.`, ) } } // Composite-creation SIGNAL (v1 — refuse loudly, never amputate): a FORM // pagespec field that resolves on none of the entity's fields[] is a value // the Create DTO will never carry — model binding drops it silently and // the UC's flow steps "never reach the API" (the vehicle's plate, the // licence's categories: 6 Create DTOs amputated on one client project). // With `moduleEntities` (Phase 3a passes the prd.entities map) the message // NAMES the child entity and its FK; composite creation itself is the // named follow-up `creation-composite`. if (spec.views.includes('form')) { const statusCamel = lc.statusField ? toCamelFirst(lc.statusField) : undefined for (const pf of spec.pageSpec?.fields ?? []) { const key = toCamelFirst(pf.key) if (knownFieldCamels.has(key)) continue if (statusCamel === key) continue if (/^code$/i.test(key)) continue // the coded-entity gate owns it const child = (spec.moduleEntities ?? []).find(me => me.fields.some(f => toCamelFirst(f) === key) && me.fks.some(fk => fk.target === spec.entity), ) const childFk = child?.fks.find(fk => fk.target === spec.entity) errors.push(child ? `[pageSpec.fields.${pf.key}] belongs to ${child.name} (child of ${spec.entity} via ${childFk!.field}) — ` + `composite creation is NOT supported by the scaffolded form: the Create DTO never carries the value ` + `and model binding drops it silently. Remove it from the form pagespec; create the ${child.name} row ` + `from the fiche's 360 tab, or capture it through a post-creation workflow action ` + `(named follow-up: creation-composite).` : `[pageSpec.fields.${pf.key}] resolves on none of the entity's fields[] — the value would be silently ` + `dropped by model binding (never in the Create DTO). Fix the pagespec (PRD-122) or the fields[] input.`) } } // PWA meta coherence (SSOT lib/pwa-meta.ts) — pageSpec.pwa wins over the // top-level mirror; both are gated so a 'full'/incoherent meta never // reaches the generator. const pwaMeta = spec.pageSpec?.pwa ?? spec.pwa if (pwaMeta) { const pwaErr = validatePwaMetaV1(pwaMeta) if (pwaErr) errors.push(`[pwa] ${pwaErr}`) else if ( normalizeOffline(pwaMeta.offline) !== 'none' && spec.views.length === 1 && spec.views[0] === 'form' ) { warnings.push( `[pwa] offline declared on a form-only spec — the form itself works offline only in 'write' mode ` + `(outbox capture); in 'read' mode its submit is disabled offline. Confirm this is intended.`, ) } } if (errors.length > 0) return { valid: false, errors, warnings } const { tabs } = parseRelatedTabs(spec.pageSpec?.relatedTabs) const dataKeys = new Set((spec.relatedTabsData ?? []).map(d => d.key)) for (const tab of tabs) { if ((tab.displayMode === 'table' || tab.displayMode === 'cards') && !dataKeys.has(tab.key)) { warnings.push( `[pageSpec.relatedTabs.${tab.key}] no relatedTabsData entry — the embedded ${tab.displayMode} ` + `falls back to a single createdAt column (derive it from ${tab.relatedEntity}'s list pagespec)`, ) } } return { valid: true, errors: [], warnings } } return { valid: false, errors: result.error.issues.map(i => `[${i.path.join('.')}] ${i.message}`), warnings: [], } }