/** * cli:derive-detail-summary — execute.ts * * Pure cores (`deriveDetailSummaryForPagespec` / `checkDetailSummaryOfPagespec`) * + a thin filesystem wrapper. The derive core takes one pagespec markdown * source and the module's `entité.md` — no I/O, unit-testable, IDEMPOTENT (an * existing `summary` or `uiDesign.detail.summary` is never touched; re-running * yields `already`). The check core is the PRD-134 engine. */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { basename, isAbsolute, join } from 'node:path' import { parseDisplayFieldLine, PREFERRED_DISPLAY_FIELDS } from '../../../../lib/display-field.js' import { DETAIL_SUMMARY_DEMAND_THRESHOLD, entityViewsFromPagespecFilenames, resolveDetailStrip, } from '../../../../lib/detail-tab-strip.js' import { pickUiDesignOverlay } from '../../../../lib/ui-design-overlay.js' import { parseEntityStatusEnums, STATE_NAME_RE } from '../derive-lifecycle/execute.js' import type { DeriveDetailSummaryReport, DeriveDetailSummarySpec, DetailSummaryFinding, DetailSummaryOutcome, } 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```/ function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } 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)}` } } } export interface EntityAttribute { /** Attribute name exactly as authored (PascalCase usually). */ name: string type: string constraints: string /** The `Calculé` cell — `—`/empty = stored. */ computed: string } /** Audit/system columns that never belong in a summary band. */ const SUMMARY_EXCLUDED_NAMES = new Set([ 'id', 'tenantId', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy', 'deletedAt', 'deletedBy', ]) /** * Parse the attribute table of ONE entity block of `entité.md` * (`| Attribut | Type | Contraintes | Calculé |` rows, declaration order). * Same block scoping as parseEntityStatusEnums — a sibling's rows never leak. */ export function parseEntityAttributes(entiteMd: string, entity: string): EntityAttribute[] { 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 out: EntityAttribute[] = [] const rowRe = /^\|\s*([A-Za-z][A-Za-z0-9]*)\s*\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|/gm let m: RegExpExecArray | null while ((m = rowRe.exec(block)) !== null) { if (m[1] === 'Attribut') continue // header row out.push({ name: m[1]!, type: m[2]!.trim(), constraints: m[3]!.trim(), computed: m[4]!.trim() }) } return out } function hasUiDesignDetailSummary(block: Record): boolean { const overlay = pickUiDesignOverlay(block) const detail = overlay?.detail as { summary?: unknown } | undefined return detail?.summary !== undefined && detail?.summary !== null } /** * DERIVE core. Writes ONLY from authored anchors: * - titleField ← the `**Affichage**` line (camelised); `Id` → `opt-out` * (nothing written — a GUID band title adds nothing, the BA refused a * label consciously). No line → the lib display cascade * (PREFERRED_DISPLAY_FIELDS priority order) over the entity's stored * string attributes; nothing resolves → `needs-judgment` (fail-closed, * mirror of scaffold-business pickDisplayField — no invention). * - statusField ← EXACTLY one state-semantics enum (STATE_NAME_RE over * parseEntityStatusEnums). 0 or >1 → the slot is OMITTED with the reason * noted: the band's status badge is decorative — omitting is an omission, * not an invention, and /ui-design (`uiDesign.detail.summary`) stays the * judgment channel for the ambiguous case (contrast derive-lifecycle, * where a wrong anchor would be destructive → needs-judgment there). * - fields ← ≤ 4 meta attributes in DECLARATION order: required * (`requis`/`required` in Contraintes), not Guid-typed (DEV-UI-033's * class), stored (`Calculé` = `—`/empty), not an audit column, not the * title/status. No intersection with the pagespec fields[] — the renderer * drops an unknown key with a warning (documented), and PRD-119 anchors * every key on entité.md, which this derivation guarantees. */ export function deriveDetailSummaryForPagespec( md: string, path: string, entiteMd: string, ): { md: string; outcome: DetailSummaryOutcome } { 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) !== 'detail') { return { md, outcome: { path, entity, status: 'skipped', reason: 'not a detail view' } } } if (entity === null) { return { md, outcome: { path, entity, status: 'skipped', reason: 'no entity in the machine block' } } } if ((block.summary !== undefined && block.summary !== null) || hasUiDesignDetailSummary(block)) { return { md, outcome: { path, entity, status: 'already' } } } const attributes = parseEntityAttributes(entiteMd, entity) // --- titleField --- const authored = parseDisplayFieldLine(entiteMd, entity) let titlePascal: string | null = authored if (authored === 'Id') { return { md, outcome: { path, entity, status: 'opt-out', reason: '**Affichage** : Id — the BA consciously refused a display label; a GUID band title adds nothing', }, } } if (titlePascal === null) { const stored = attributes.filter( (a) => /^string/i.test(a.type) && (a.computed === '' || a.computed === '—'), ) titlePascal = PREFERRED_DISPLAY_FIELDS.find((p) => stored.some((a) => a.name === p)) ?? null } if (titlePascal === null) { return { md, outcome: { path, entity, status: 'needs-judgment', reason: 'no **Affichage** line and no display-family attribute — author the - **Résumé** : bullet (/ba-create-screen) or the Affichage line (/ba-create-data-model); no mechanical invention', }, } } const titleField = toCamelFirst(titlePascal) // --- statusField --- const stateEnums = parseEntityStatusEnums(entiteMd, entity).filter((e) => STATE_NAME_RE.test(e.name)) const statusField = stateEnums.length === 1 ? toCamelFirst(stateEnums[0]!.name) : undefined const statusReason = stateEnums.length > 1 ? `${stateEnums.length} state-semantics enums (${stateEnums.map((e) => e.name).join(', ')}) — statusField omitted, /ui-design decides` : undefined // --- meta fields (≤ 4, declaration order) --- const excluded = new Set([toCamelFirst(titlePascal), ...(statusField ? [statusField] : [])]) const metaFields = attributes .filter((a) => /requis|required/i.test(a.constraints) && !/^(guid|uuid)/i.test(a.type) && (a.computed === '' || a.computed === '—') && !SUMMARY_EXCLUDED_NAMES.has(toCamelFirst(a.name)) && !excluded.has(toCamelFirst(a.name))) .map((a) => toCamelFirst(a.name)) .slice(0, 4) const summary = { titleField, ...(statusField !== undefined ? { statusField } : {}), ...(metaFields.length > 0 ? { fields: metaFields } : {}), } block.summary = summary 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', summary, ...(statusReason ? { reason: statusReason } : {}) } } } /** * CHECK core — the deterministic engine of PRD-134: a detail pagespec whose * RENDERED strip carries ≥ DETAIL_SUMMARY_DEMAND_THRESHOLD triggers * (lib/detail-tab-strip resolveDetailStrip) and neither `summary` nor * `uiDesign.detail.summary` → ONE finding. Below the threshold, absence is * never a finding (PRD-112(a) twin discipline — never breaks a legacy PRD). */ export function checkDetailSummaryOfPagespec( md: string, path: string, entityViews: Set | undefined, ): DetailSummaryFinding[] { const parsed = parseMachineBlock(md) if ('error' in parsed) return [] const { block } = parsed if ((typeof block.view === 'string' ? block.view : null) !== 'detail') return [] if ((block.summary !== undefined && block.summary !== null) || hasUiDesignDetailSummary(block)) return [] const strip = resolveDetailStrip(block, entityViews) if (strip.stripTotal < DETAIL_SUMMARY_DEMAND_THRESHOLD) return [] return [{ path, message: `${strip.stripTotal} rendered strip tabs and no summary band — identity and state vanish once the reader ` + `leaves the first tab. Run derive-detail-summary --mode derive (or author the - **Résumé** : bullet in ` + `screen.md / uiDesign.detail.summary via /ui-design).`, }] } /** Resolve the pagespec file list from the spec (explicit paths win). */ export function resolvePagespecPaths(spec: DeriveDetailSummarySpec, 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: DeriveDetailSummarySpec, workdir: string): DeriveDetailSummaryReport { 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 paths = resolvePagespecPaths(spec, workdir) const entityViews = entityViewsFromPagespecFilenames(paths.map((p) => basename(p))) const outcomes: DetailSummaryOutcome[] = [] const findings: DetailSummaryFinding[] = [] let written = 0 for (const path of paths) { const md = readFileSync(path, 'utf-8') if (spec.mode === 'check') { const parsed = parseMachineBlock(md) const entity = 'error' in parsed ? null : (typeof parsed.block.entity === 'string' ? parsed.block.entity : null) findings.push(...checkDetailSummaryOfPagespec(md, path, entity !== null ? entityViews.get(entity) : undefined)) continue } const { md: md2, outcome } = deriveDetailSummaryForPagespec(md, path, entiteMd) outcomes.push(outcome) if (outcome.status === 'derived' && md2 !== md) { writeFileSync(path, md2, 'utf-8') written++ } } return { outcomes, findings, written } }