/** * cli:apply-form-directives — execute.ts * * Pure core (`applyDirectivesToPagespec`) + a thin filesystem wrapper. The pure * core takes the pagespec markdown source and the directives and returns the * rewritten markdown + the merged overlay — no I/O, so it is unit-testable. */ import { readFileSync, writeFileSync } from 'node:fs' import type { UiDesignOverlay, UiDesignSectionMeta } from '../../../lib/ui-design-overlay.js' import type { ApplyFormDirectivesInput } from './types.js' /** Matches the FIRST fenced ```json … ``` block (the pagespec machine block). */ const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ /** * Merge the judgment directives into the pagespec's machine block under a * namespaced `uiDesign` overlay, preserving every other key. Returns the new * markdown and the resulting overlay. Idempotent: re-applying the same * directives yields the same document; new directives MERGE over existing ones. */ export function applyDirectivesToPagespec( md: string, input: ApplyFormDirectivesInput, ): { md: string; overlay: UiDesignOverlay; warnings: string[] } { const m = JSON_BLOCK_RE.exec(md) if (!m) { throw new Error('apply-form-directives: no ```json machine block found in the pagespec') } let block: Record try { block = JSON.parse(m[1]!) as Record } catch (e) { throw new Error(`apply-form-directives: machine block is not valid JSON — ${e instanceof Error ? e.message : String(e)}`) } const warnings: string[] = [] const prev = (block.uiDesign as UiDesignOverlay | undefined) ?? {} const fieldsOverlay: NonNullable = { ...(prev.fields ?? {}), } for (const f of input.fields ?? []) { const o = { ...(fieldsOverlay[f.key] ?? {}) } if (f.currentUserFk !== undefined) o.currentUserFk = f.currentUserFk if (f.section !== undefined) o.section = f.section if (f.control !== undefined) o.control = f.control if (f.dateBounds !== undefined) o.dateBounds = f.dateBounds if (f.fullWidth !== undefined) o.fullWidth = f.fullWidth if (Object.keys(o).length > 0) fieldsOverlay[f.key] = o } // sections[] sugar → canonical halves: per-field membership + ordered metadata. // Membership never has two sources in the overlay (fields..section only). if (input.sections !== undefined) { const specFieldKeys = new Set( (Array.isArray(block.fields) ? block.fields : []) .map(f => (f !== null && typeof f === 'object' ? (f as { key?: unknown }).key : undefined)) .filter((k): k is string => typeof k === 'string'), ) for (const s of input.sections) { for (const key of s.fields) { if (specFieldKeys.size > 0 && !specFieldKeys.has(key)) { warnings.push(`sections.${s.key}: field "${key}" is not declared in the pagespec fields[] — check the key.`) } fieldsOverlay[key] = { ...(fieldsOverlay[key] ?? {}), section: s.key } } } // Metadata: the LAST call owns the order for the keys it mentions; keys it // does not mention keep their previous relative order, appended after. const meta: UiDesignSectionMeta[] = input.sections.map(({ fields: _members, ...rest }) => ({ ...rest })) const mentioned = new Set(meta.map(s => s.key)) for (const prevMeta of prev.sections ?? []) { if (!mentioned.has(prevMeta.key)) meta.push(prevMeta) } prev.sections = meta } const overlay: UiDesignOverlay = { ...prev } if (input.formLayout !== undefined) overlay.formLayout = input.formLayout if (input.editMode !== undefined) overlay.editMode = input.editMode if (Object.keys(fieldsOverlay).length > 0) overlay.fields = fieldsOverlay if (input.order !== undefined) overlay.order = input.order if (input.list !== undefined) { if (block.view !== 'list') { warnings.push(`list directives applied to a '${String(block.view)}' pagespec — the reader only honours them on view: list.`) } overlay.list = { ...(prev.list ?? {}), ...input.list } } if (input.detail !== undefined) { if (block.view !== 'detail') { warnings.push(`detail directives applied to a '${String(block.view)}' pagespec — the reader only honours them on view: detail.`) } overlay.detail = { ...(prev.detail ?? {}), ...input.detail } } // Lifecycle (rubric §6) — a FIRST-ORDER pagespec key, never an overlay // member. ADDITIVE: an absent block is written whole; an existing block only // gains NEW phase keys (existing phases + statusField are never modified — // an already-paid judgment is never rewritten). if (input.lifecycle !== undefined) { const existing = block.lifecycle if (existing === undefined || existing === null) { block.lifecycle = input.lifecycle } else if (typeof existing === 'object') { const ex = existing as { statusField?: unknown; phases?: unknown[] } const exPhases = Array.isArray(ex.phases) ? ex.phases : [] const exKeys = new Set( exPhases .map(p => (p !== null && typeof p === 'object' ? (p as { key?: unknown }).key : undefined)) .filter((k): k is string => typeof k === 'string'), ) const additions = input.lifecycle.phases.filter(p => !exKeys.has(p.key)) if (additions.length > 0) ex.phases = [...exPhases, ...additions] if (ex.statusField !== input.lifecycle.statusField) { warnings.push(`lifecycle: existing statusField '${String(ex.statusField)}' kept — the block is additive, '${input.lifecycle.statusField}' ignored.`) } } } // A lifecycle-only call must NOT plant an empty `uiDesign` key — that would // flip Phase 3.1's form-design idempotency (keyed on its presence) for // nothing. The overlay is written only when it carries something (or was // already there). if (Object.keys(overlay).length > 0 || block.uiDesign !== undefined) { block.uiDesign = overlay } const newJson = JSON.stringify(block, null, 2) const md2 = md.slice(0, m.index) + '```json\n' + newJson + '\n```' + md.slice(m.index + m[0].length) return { md: md2, overlay, warnings } } /** Filesystem wrapper: read the pagespec, apply, write back. */ export function execute(input: ApplyFormDirectivesInput): { pagespecPath: string; overlay: UiDesignOverlay; warnings: string[] } { const md = readFileSync(input.pagespecPath, 'utf-8') const { md: md2, overlay, warnings } = applyDirectivesToPagespec(md, input) writeFileSync(input.pagespecPath, md2, 'utf-8') return { pagespecPath: input.pagespecPath, overlay, warnings } }