/** * lib/page-spec-lifecycle.ts — Canonical Zod schema + resolver for the * first-order `lifecycle` block of a FORM pagespec. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` (Cycle de vie bullet) → * `pagespec.lifecycle` → scaffold-component (create-mode exclusion + status * gates), the backend field derivation (Create DTO exclusion) and the audits * that verify it (SCR-019, PRD-120/121, DEV-UI-044). * * WHY: a form pagespec serves BOTH create and edit, and before this module the * generated create form asked every field of the entity — an invoice's CREATE * form asked the payment date, an employee's asked the departure fields. A * lifecycle PHASE names the business moment a field belongs to, anchored on * the entity's status enum values (the same tokens the Flow business rules * use — the Flow graph stays the transition SSOT, this block never re-declares * transitions): * * - `phases[].fields` (OWNED) — the field is captured LATER than creation: * excluded from the CREATE render/payload (and from the backend Create DTO), * visible on EDIT gated by the phase's statuses. * - `phases[].requiredFields` — the field becomes MANDATORY once the phase's * statuses are reached (status-guarded validation). An entry NOT also in * `fields` stays visible on the create form as an optional input (the * draft → submitted pattern: create incomplete, complete before submitting). * * Two invariants the audits enforce (PRD-120) and the generator degrades on: * - a phase field is NEVER `required: true` in the pagespec — the backend * Create DTO carries required fields, so a required later-phase field would * re-enter the create contract server-side; its column must stay nullable. * - `statusField` must be one of the form's fields — the compiled guards read * `formData.`; absent, the generator degrades every phase gate * to a bare edit-mode guard (and validate.ts warns). * * @see business-analyse/create-screen/levels/form-screens.md (authors the Cycle de vie bullet) * @see business-analyse/create-prd/SKILL.md (propagates screen.md → pagespec.lifecycle) * @see business-analyse/create-prd/cli/derive-lifecycle (deterministic backfill + PRD-120 check engine) * @see ui-design/cli/apply-form-directives (persists the §6 lifecycle judgment — first-order, additive) * @see development/frontend/component/cli/scaffold-component (compiles the block down to render guards) * * Deliberately DEFERRED from v1: * - a server-side phase gate on PUT (a crafted update can set an owned field * while the status has not reached the phase) — follow-up in scaffold-business; * - a conditional "required" asterisk on the form (the guarded validation is * the contract; the label polish can land without a schema change); * - phase ordering / graph in the pagespec — the Flow BR already is the graph. */ import { z } from 'zod' /** Reserved phase key: un-phased fields ARE the creation phase. Authoring it * is meaningless (and PRD-120 errs) — the resolver skips it with a report. */ export const RESERVED_PHASE_KEY = 'creation' export const PageLifecyclePhaseSchema = z.object({ /** * Stable phase key in lower-camel/kebab (e.g. `paiement`, `sortie`). It is * the value written onto `field.phase` (the resolved internal pivot, mirror * of `field.section`) and the "phase:" JSX comment marker DEV-UI-044 greps. */ key: z .string() .min(1) .regex(/^[a-z][a-zA-Z0-9-]*$/, 'key must be lower-camel or kebab-case'), /** Documentation-only label — NEVER rendered, carries NO i18n key. */ label: z.string().optional(), /** * Status enum values (VERBATIM from entité.md / the Flow BR tokens) during * which the phase's fields are live on the edit surface. An explicit list — * never "reached or later" (the Flow graph is a graph, not a total order). * Absent → owned fields are visible on edit unconditionally. */ statuses: z.array(z.string().min(1)).optional(), /** * Custom-action code (pagespec `actions[].code`) whose payloadParameters * capture these fields — the sanctioned write path (e.g. `marquerPayee`). * Audit metadata + derive-lifecycle anchor; no direct rendering effect. */ capturedBy: z.string().regex(/^[a-z][a-zA-Z0-9]*$/).optional(), /** * OWNED pagespec field keys (camelCase): captured later than creation — * hidden on the create form, excluded from the create payload and the * backend Create DTO, edit-visible gated by `statuses`. */ fields: z.array(z.string().min(1)).optional(), /** * Field keys that become REQUIRED once `statuses` is reached (guarded * validation). May overlap `fields`; an entry NOT in `fields` stays a * visible optional input on the create form (draft pattern) and — unlike * owned fields — DOES enter the backend Create DTO (as nullable). */ requiredFields: z.array(z.string().min(1)).optional(), }).passthrough().refine( p => ((p.fields?.length ?? 0) + (p.requiredFields?.length ?? 0)) > 0, { message: 'a phase must own fields and/or requiredFields' }, ) export type PageLifecyclePhase = z.infer export const PageLifecycleSchema = z.object({ /** * camelCase pagespec field key of the state-semantics enum attribute * (XD-001 definition). Must be listed in the form's fields[] — readonly / * `readonlyOn: 'create'` is fine, the guards only need it in formData. */ statusField: z.string().min(1), phases: z.array(PageLifecyclePhaseSchema).min(1), }).passthrough() export type PageLifecycle = z.infer /** Resolved metadata of ONE phase (reserved `creation` entries are dropped). */ export interface ResolvedLifecyclePhase { key: string label?: string statuses?: string[] capturedBy?: string } /** Per-field compile-down unit the renderers consume. */ export interface LifecycleFieldEffect { /** Owning/requiring phase key. */ phase: string /** The phase's statuses (verbatim), when declared. */ statuses?: string[] /** true → hidden at create, edit-gated (a `phases[].fields` entry). */ owned: boolean /** true → status-guarded required validation. */ requiredInPhase: boolean } /** * Parse a pagespec's raw `lifecycle` block. Never throws: an invalid block is * returned as `rejected` issues (audits turn each into a finding; the * generator warns and renders WITHOUT lifecycle — the legacy output, always * safe). */ export function parsePageLifecycle(raw: unknown): { lifecycle: PageLifecycle | undefined rejected: string[] } { if (raw === undefined || raw === null) return { lifecycle: undefined, rejected: [] } const result = PageLifecycleSchema.safeParse(raw) if (result.success) return { lifecycle: result.data, rejected: [] } return { lifecycle: undefined, rejected: result.error.issues.map(i => `lifecycle.${i.path.join('.')}: ${i.message}`), } } function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } /** * Synthesize the status-gate predicate in the `visibleWhen` grammar * (`compileVisibleWhen` compiles it against the live form data): one status → * the legacy single comparison, several → the `IN ('a', 'b')` membership form. */ export function lifecycleVisibleWhen(statusField: string, statuses: string[]): string { const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") if (statuses.length === 1) return `${statusField} === '${esc(statuses[0]!)}'` return `${statusField} IN (${statuses.map(s => `'${esc(s)}'`).join(', ')})` } /** * Seed `field.phase` / `field.requiredInPhase` from the pagespec's first-order * `lifecycle` block and return the per-field effects map (keyed by the * camelCase field key). Pure, total, deterministic: * - absent/invalid block → identity (fields untouched, empty effects) — the * legacy render, byte-identical; * - a field with an explicit `phase` is never overwritten (mirror of the * `section` precedence — the scaffold spec is the stronger judgment); * - the FIRST phase claiming a field wins; later claims are reported; * - the statusField itself, unknown keys and the reserved `creation` phase * are reported in `rejected`, never silently dropped; * - a `requiredFields`-only entry in a phase WITHOUT `statuses` has no * expressible guard → reported, no effect (PRD-120 errs upstream). * * `statusFieldOnForm` tells the caller whether the compiled guards can read * `formData.` — false → degrade every gate to edit-mode-only. */ export function resolveLifecycle( fields: F[], pageSpec: { lifecycle?: unknown } | undefined, ): { fields: F[] statusField?: string statusFieldOnForm: boolean phases: ResolvedLifecyclePhase[] effects: Map rejected: string[] } { const { lifecycle, rejected } = parsePageLifecycle(pageSpec?.lifecycle) const effects = new Map() if (!lifecycle) return { fields, statusFieldOnForm: false, phases: [], effects, rejected } const statusCamel = toCamelFirst(lifecycle.statusField) const byCamel = new Map(fields.map(f => [toCamelFirst(f.name), f])) const statusFieldOnForm = byCamel.has(statusCamel) const phases: ResolvedLifecyclePhase[] = [] for (const p of lifecycle.phases) { if (p.key === RESERVED_PHASE_KEY) { rejected.push(`lifecycle.phases.${p.key}: reserved key — un-phased fields ARE the creation phase`) continue } phases.push({ key: p.key, label: p.label, statuses: p.statuses, capturedBy: p.capturedBy }) const requiredSet = new Set((p.requiredFields ?? []).map(toCamelFirst)) const claim = (name: string, owned: boolean): void => { const camel = toCamelFirst(name) if (camel === statusCamel) { rejected.push(`lifecycle.phases.${p.key}: the statusField '${lifecycle.statusField}' cannot belong to a phase`) return } if (!byCamel.has(camel)) { rejected.push(`lifecycle.phases.${p.key}: unknown field '${name}'`) return } const existing = effects.get(camel) if (existing) { if (existing.phase !== p.key) rejected.push(`lifecycle.phases.${p.key}: field '${name}' already claimed by phase '${existing.phase}'`) return } if (!owned && !(p.statuses?.length)) { rejected.push(`lifecycle.phases.${p.key}: requiredFields entry '${name}' needs statuses (no expressible guard without them)`) return } effects.set(camel, { phase: p.key, statuses: p.statuses, owned, requiredInPhase: owned ? requiredSet.has(camel) : true, }) } for (const name of p.fields ?? []) claim(name, true) for (const name of p.requiredFields ?? []) { const camel = toCamelFirst(name) const eff = effects.get(camel) if (eff && eff.phase === p.key && eff.owned) continue // owned+required, already set claim(name, false) } } const seeded = fields.map(f => { const eff = effects.get(toCamelFirst(f.name)) if (!eff) return f if (eff.owned) { return { ...f, ...((f.phase ?? '').trim() !== '' ? {} : { phase: eff.phase }), ...(eff.requiredInPhase ? { requiredInPhase: true } : {}), } } return { ...f, requiredInPhase: true } }) return { fields: seeded, statusField: statusCamel, statusFieldOnForm, phases, effects, rejected } }