/** * cli:derive-lifecycle — execute.ts * * Pure core (`deriveLifecycleForPagespec` / `checkLifecycleOfPagespec`) + a * thin filesystem wrapper. The cores take one pagespec markdown source and the * module's parsed status enums — no I/O, unit-testable, IDEMPOTENT (an * existing `lifecycle` key is never touched; re-running yields `already`). */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { parsePageLifecycle, RESERVED_PHASE_KEY } from '../../../../lib/page-spec-lifecycle.js' import type { DeriveLifecycleReport, DeriveLifecycleSpec, DerivedPhase, LifecycleFinding, PagespecOutcome } 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```/ /** State-semantics attribute names (XD-001 definition + the French twins the * BA docs actually use). Whole-name containment, case-insensitive. Exported * for the same-skill sibling derive-detail-summary (the summary band's * statusField reuses this exact filter + parseEntityStatusEnums). */ export const STATE_NAME_RE = /(status|statut|state|etat|état|phase|step|etape|étape)/i /** Later-phase field-name lexicon — a CANDIDATE trigger only (needs-judgment), * never sufficient to write a phase (rubric §6 house rule: no invention). */ const LATER_PHASE_LEXICON_RE = /(payment|paid|paie|departure|depart|sortie|exit|end(ed|Date|At)|termination|closure|closed|cancel|annulation|resolution|resolu|résolu|archiv)/i function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } export interface StatusEnum { /** Attribute name exactly as authored in entité.md (PascalCase usually). */ name: string values: string[] } /** * Parse the status enums of ONE entity from `entité.md`. The doc is the module * data dictionary (`### ENT-xxx — …` blocks, each carrying a * `| Attribut | Type | Contraintes | Calculé |` table); an enum attribute's * values live in the Contraintes cell as `A/B/C` (verbatim tokens). Scoped to * the entity's own block so a sibling entity's enum never leaks in. */ export function parseEntityStatusEnums(entiteMd: string, entity: string): StatusEnum[] { const blocks = entiteMd.split(/^###\s+/m) const entityRe = new RegExp(`(^|[^A-Za-z0-9])${entity}([^A-Za-z0-9]|$)`) const block = blocks.find((b) => entityRe.test(b.split(/\r?\n/, 1)[0] ?? '')) if (block === undefined) return [] const enums: StatusEnum[] = [] const rowRe = /^\|\s*([A-Za-z][A-Za-z0-9]*)\s*\|\s*enum\s*\|\s*([^|]*)\|/gim let m: RegExpExecArray | null while ((m = rowRe.exec(block)) !== null) { const name = m[1]! const constraint = m[2]! const valuesMatch = /([A-Za-z0-9_ÀÂÉÈÊËÎÏÔÙÛÜÇàâéèêëîïôùûüç-]+(?:\s*\/\s*[A-Za-z0-9_ÀÂÉÈÊËÎÏÔÙÛÜÇàâéèêëîïôùûüç-]+)+)/.exec(constraint) if (!valuesMatch) continue const values = valuesMatch[1]!.split('/').map((v) => v.trim()).filter((v) => v !== '') if (values.length >= 2) enums.push({ name, values }) } return enums } interface ParsedBlock { block: Record raw: RegExpExecArray } function parseMachineBlock(md: string): ParsedBlock | { error: string } { const m = JSON_BLOCK_RE.exec(md) if (!m) return { error: 'no ```json machine block' } try { return { block: JSON.parse(m[1]!) as Record, raw: m } } catch (e) { return { error: `machine block is not valid JSON — ${e instanceof Error ? e.message : String(e)}` } } } function fieldKeysOf(block: Record): string[] { return (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') } type ActionLike = { code?: unknown kind?: unknown workflowTransition?: { fromStatus?: unknown; toStatus?: unknown; flowParameters?: unknown } payloadParameters?: Array<{ name?: unknown; field?: unknown; required?: unknown }> } function workflowActionsOf(block: Record): ActionLike[] { return (Array.isArray(block.actions) ? (block.actions as ActionLike[]) : []) .filter((a) => a !== null && typeof a === 'object') .filter((a) => (a.kind ?? 'api') === 'api' && a.workflowTransition !== undefined && a.workflowTransition !== null) } /** * DERIVE core. Writes ONLY on full determinism: * - exactly ONE state-semantics enum attribute on the entity (entité.md); * - ≥ 1 `kind: api` action carrying a `workflowTransition` whose `toStatus` is * one of the enum's VERBATIM values and whose parameters (payloadParameters * `field` ?? `name`, else `flowParameters` names) match form field keys * (statusField excluded). * Each matching action → one phase `{key: , statuses: [toStatus], * capturedBy: , fields: , requiredFields: }`. * The lexicon only FLAGS `needs-judgment` — it never writes. */ export function deriveLifecycleForPagespec( md: string, path: string, statusEnums: StatusEnum[], ): { md: string; outcome: PagespecOutcome } { const parsed = parseMachineBlock(md) if ('error' in parsed) return { md, outcome: { path, entity: null, status: 'skipped', reason: parsed.error } } const { block, raw } = parsed const entity = typeof block.entity === 'string' ? block.entity : null if ((typeof block.view === 'string' ? block.view : null) !== 'form') { return { md, outcome: { path, entity, status: 'skipped', reason: 'not a form view' } } } if (block.lifecycle !== undefined && block.lifecycle !== null) { return { md, outcome: { path, entity, status: 'already' } } } const stateEnums = statusEnums.filter((e) => STATE_NAME_RE.test(e.name)) if (stateEnums.length === 0) { return { md, outcome: { path, entity, status: 'no-state' } } } const fieldKeys = fieldKeysOf(block) const lexiconCandidates = fieldKeys.filter((k) => LATER_PHASE_LEXICON_RE.test(k)) if (stateEnums.length > 1) { return { md, outcome: { path, entity, status: 'needs-judgment', candidateFields: lexiconCandidates, reason: `${stateEnums.length} state-semantics enums (${stateEnums.map((e) => e.name).join(', ')}) — ambiguous anchor, decide the statut pilote`, }, } } const anchor = stateEnums[0]! const statusFieldCamel = toCamelFirst(anchor.name) const fieldKeySet = new Set(fieldKeys.map(toCamelFirst)) const phases: DerivedPhase[] = [] for (const a of workflowActionsOf(block)) { const code = typeof a.code === 'string' ? a.code : null const wf = a.workflowTransition! const toStatus = typeof wf.toStatus === 'string' ? wf.toStatus : null if (code === null || toStatus === null) continue // Status anchoring is VERBATIM — a toStatus outside the enum is not an anchor. if (!anchor.values.includes(toStatus)) continue const params = Array.isArray(a.payloadParameters) ? a.payloadParameters : [] const paramKeys: Array<{ key: string; required: boolean }> = params.length > 0 ? params .map((p) => ({ key: typeof p.field === 'string' ? p.field : (typeof p.name === 'string' ? p.name : ''), required: p.required === true, })) .filter((p) => p.key !== '') : (Array.isArray(wf.flowParameters) ? wf.flowParameters : []) .filter((n): n is string => typeof n === 'string') .map((n) => ({ key: n, required: false })) const matched = paramKeys.filter((p) => { const camel = toCamelFirst(p.key) return camel !== statusFieldCamel && fieldKeySet.has(camel) }) if (matched.length === 0) continue const requiredFields = matched.filter((p) => p.required).map((p) => toCamelFirst(p.key)) phases.push({ key: code, statuses: [toStatus], capturedBy: code, fields: matched.map((p) => toCamelFirst(p.key)), ...(requiredFields.length > 0 ? { requiredFields } : {}), }) } if (phases.length === 0) { if (lexiconCandidates.length > 0) { return { md, outcome: { path, entity, status: 'needs-judgment', candidateFields: lexiconCandidates, reason: 'later-phase-looking fields with no deterministic action anchor — run the /ui-design rubric §6 pass (no mechanical invention)', }, } } return { md, outcome: { path, entity, status: 'no-anchor' } } } block.lifecycle = { statusField: statusFieldCamel, phases } const newJson = JSON.stringify(block, null, 2) const md2 = md.slice(0, raw.index) + '```json\n' + newJson + '\n```' + md.slice(raw.index + raw[0].length) return { md: md2, outcome: { path, entity, status: 'derived', phases } } } /** * CHECK core — the deterministic engine of PRD-120 (a-d, f; leg e — phased * fields never `required: true` — lives where `required` lives: the scaffold * spec gate in scaffold-component/validate.ts and the audit prose). Only * pagespecs CARRYING a lifecycle block produce findings; absence is never a * finding (PRD-121's warn half is judgment, not this engine's job). */ export function checkLifecycleOfPagespec( md: string, path: string, statusEnums: StatusEnum[], ): LifecycleFinding[] { const parsed = parseMachineBlock(md) if ('error' in parsed) return [] const { block } = parsed if ((typeof block.view === 'string' ? block.view : null) !== 'form') return [] if (block.lifecycle === undefined || block.lifecycle === null) return [] const findings: LifecycleFinding[] = [] const { lifecycle, rejected } = parsePageLifecycle(block.lifecycle) for (const issue of rejected) findings.push({ path, leg: 'schema', message: issue }) if (!lifecycle) return findings const statusCamel = toCamelFirst(lifecycle.statusField) const fieldKeys = fieldKeysOf(block).map(toCamelFirst) const fieldKeySet = new Set(fieldKeys) // (a) statusField anchors on a state-semantics enum of the entity const anchor = statusEnums.find((e) => toCamelFirst(e.name) === statusCamel) if (!anchor) { findings.push({ path, leg: 'a', message: `statusField '${lifecycle.statusField}' does not name an enum attribute of the entity in entité.md` }) } // (b) statusField is one of the form's fields (the compiled guards read it) if (!fieldKeySet.has(statusCamel)) { findings.push({ path, leg: 'b', message: `statusField '${lifecycle.statusField}' is not in the pagespec fields[] — every phase gate degrades to a bare edit-mode guard` }) } const claimed = new Set() const actions = (Array.isArray(block.actions) ? (block.actions as ActionLike[]) : []).filter((a) => a && typeof a === 'object') for (const p of lifecycle.phases) { if (p.key === RESERVED_PHASE_KEY) { findings.push({ path, leg: 'd', message: `phase key '${RESERVED_PHASE_KEY}' is reserved — un-phased fields ARE the creation phase` }) continue } // (c) statuses ⊆ the enum's VERBATIM values if (anchor) { for (const s of p.statuses ?? []) { if (!anchor.values.includes(s)) { findings.push({ path, leg: 'c', message: `phase '${p.key}': status '${s}' is not a verbatim value of ${anchor.name} (${anchor.values.join('/')})` }) } } } // (d) membership: known fields, never the statusField, no double claim const members = [...(p.fields ?? []), ...(p.requiredFields ?? [])].map(toCamelFirst) for (const f of members) { if (f === statusCamel) findings.push({ path, leg: 'd', message: `phase '${p.key}': the statusField cannot belong to a phase` }) else if (!fieldKeySet.has(f)) findings.push({ path, leg: 'd', message: `phase '${p.key}': unknown field '${f}'` }) } for (const f of (p.fields ?? []).map(toCamelFirst)) { if (claimed.has(f)) findings.push({ path, leg: 'd', message: `field '${f}' is owned by two phases` }) claimed.add(f) } // (f) capturedBy coherence if (p.capturedBy !== undefined) { const action = actions.find((a) => a.code === p.capturedBy) if (!action || (action.kind ?? 'api') !== 'api') { findings.push({ path, leg: 'f', message: `phase '${p.key}': capturedBy '${p.capturedBy}' names no kind:api action of this pagespec` }) } else { const toStatus = typeof action.workflowTransition?.toStatus === 'string' ? action.workflowTransition.toStatus : null if (toStatus !== null && (p.statuses?.length ?? 0) > 0 && !p.statuses!.includes(toStatus)) { findings.push({ path, leg: 'f', message: `phase '${p.key}': the capturing action's toStatus '${toStatus}' is not among the phase statuses` }) } const params = Array.isArray(action.payloadParameters) ? action.payloadParameters : [] const paramKeys = new Set(params.map((pp) => toCamelFirst(typeof pp.field === 'string' ? pp.field : (typeof pp.name === 'string' ? pp.name : '')))) for (const f of (p.fields ?? []).map(toCamelFirst)) { if (!paramKeys.has(f)) { findings.push({ path, leg: 'f', message: `phase '${p.key}': owned field '${f}' has no payloadParameters entry on '${p.capturedBy}' (add one with field: '${f}')` }) } } for (const f of (p.requiredFields ?? []).map(toCamelFirst)) { const pp = params.find((x) => toCamelFirst(typeof x.field === 'string' ? x.field : (typeof x.name === 'string' ? x.name : '')) === f) if (pp && pp.required !== true) { findings.push({ path, leg: 'f', message: `phase '${p.key}': '${f}' is phase-required but the capturing parameter is not required: true` }) } } } } } return findings } /** Resolve the pagespec file list from the spec (explicit paths win). */ export function resolvePagespecPaths(spec: DeriveLifecycleSpec, workdir: string): string[] { const abs = (p: string) => (isAbsolute(p) ? p : join(workdir, p)) if (spec.pagespecs !== undefined) return spec.pagespecs.map(abs) const dir = abs(spec.pagespecDir!) return readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => join(dir, f)) .filter((p) => statSync(p).isFile()) } /** Filesystem wrapper: read entité.md once, then derive/check each pagespec. */ export function execute(spec: DeriveLifecycleSpec, workdir: string): DeriveLifecycleReport { const moduleRoot = isAbsolute(spec.moduleRoot) ? spec.moduleRoot : join(workdir, spec.moduleRoot) const entitePath = join(moduleRoot, 'entité.md') const entiteMd = existsSync(entitePath) ? readFileSync(entitePath, 'utf-8') : '' const outcomes: PagespecOutcome[] = [] const findings: LifecycleFinding[] = [] let written = 0 for (const path of resolvePagespecPaths(spec, workdir)) { const md = readFileSync(path, 'utf-8') const parsed = parseMachineBlock(md) const entity = 'error' in parsed ? null : (typeof parsed.block.entity === 'string' ? parsed.block.entity : null) const statusEnums = entity !== null && entiteMd !== '' ? parseEntityStatusEnums(entiteMd, entity) : [] if (spec.mode === 'check') { findings.push(...checkLifecycleOfPagespec(md, path, statusEnums)) continue } const { md: md2, outcome } = deriveLifecycleForPagespec(md, path, statusEnums) outcomes.push(outcome) if (outcome.status === 'derived' && md2 !== md) { writeFileSync(path, md2, 'utf-8') written++ } } return { outcomes, findings, written } }