/** * cli:derive-kanban-spec — execute.ts * * Pure cores (`deriveKanbanForPagespec` / `checkKanbanOfPagespec`) + a thin * filesystem wrapper. The cores take one pagespec markdown source and a * pre-built context (entity enums, the section's SmartKanban screen, the * module's workflow rules) — no I/O, unit-testable, IDEMPOTENT (an existing * `kanban` key is never touched; re-running yields `already`). * * Fail-closed by design: workflow rules whose Flow tokens don't map onto the * entity enum produce `needs-judgment`, never a silently OPEN matrix — the * label-vs-token mismatch class (tour 7 §49) must surface, not ship. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { basename, dirname, isAbsolute, join } from 'node:path' import { KANBAN_BA_COLORS, KANBAN_MOVE_ACTION_CODE, LIST_VIEW_MODES, parsePageKanban, type KanbanTransition, } from '../../../../lib/page-spec-kanban.js' import { loadModuleRules, type BaRule } from '../../../../lib/ba-rules-rows.js' import { loadScreens, type ParsedScreen } from '../../../../lib/ba-screens.js' import { parseEntityStatusEnums, type StatusEnum } from '../derive-lifecycle/execute.js' import type { DeriveKanbanReport, DeriveKanbanSpec, KanbanFinding, KanbanPagespecOutcome, } 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```/ const WORKFLOW_RULE_TYPE_RE = /^(workflow|state-transition)$/i const LOCALES = ['fr', 'en', 'it', 'de'] as const /** Seeded label of the auto-derived move action — a standard verb, safe to * author in all 4 locales (column labels, by contrast, are business copy and * ride the needsTranslation channel). */ const MOVE_ACTION_LABELS: Record<(typeof LOCALES)[number], string> = { fr: 'Déplacer', en: 'Move', it: 'Sposta', de: 'Verschieben', } function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } const fold = (s: string): string => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() 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 replaceMachineBlock(md: string, raw: RegExpExecArray, block: Record): string { const newJson = JSON.stringify(block, null, 2) return md.slice(0, raw.index) + '```json\n' + newJson + '\n```' + md.slice(raw.index + raw[0].length) } type ActionLike = { code?: unknown; kind?: unknown } function actionsOf(block: Record): ActionLike[] { return (Array.isArray(block.actions) ? (block.actions as ActionLike[]) : []) .filter((a) => a !== null && typeof a === 'object') } function hasMoveAction(block: Record): boolean { return actionsOf(block).some((a) => a.code === KANBAN_MOVE_ACTION_CODE) } /** Context the fs wrapper pre-builds per pagespec entity. */ export interface KanbanDeriveContext { /** The entity's enums parsed from entité.md (name + verbatim values). */ statusEnums: StatusEnum[] /** The section's SmartKanban screen (screen.md), when authored. */ kanbanScreen?: ParsedScreen /** True when a SmartListView for the SAME entity exists in the section — * drives `defaultViewMode` (a kanban-only corpus defaults to the board). */ hasListScreen: boolean /** Module workflow rules carrying Flow transitions (already filtered). */ workflowRules: BaRule[] } /** Workflow rules of a module: `Type: workflow|state-transition` + ≥1 Flow edge. */ export function workflowRulesOf(rules: BaRule[]): BaRule[] { return rules.filter((r) => WORKFLOW_RULE_TYPE_RE.test(r.type ?? '') && r.flow.length > 0) } interface MappedGraph { transitions: KanbanTransition[] dropped: string[] /** Distinct non-empty `Code d'erreur` values of the contributing rules. */ errorCodes: string[] } /** * Project the Flow edges onto the enum's VERBATIM values (case+diacritic-fold * match, verbatim emission). An edge with an unmappable token is DROPPED and * reported — never emitted with the BA's spelling (the board would bucket * nothing under it). */ export function mapFlowOntoEnum(rules: BaRule[], enumValues: string[]): MappedGraph { const byFold = new Map(enumValues.map((v) => [fold(v), v])) const transitions: KanbanTransition[] = [] const dropped: string[] = [] const errorCodes = new Set() const seen = new Set() for (const rule of rules) { let contributed = false for (const edge of rule.flow) { const from = byFold.get(fold(edge.from)) const to = byFold.get(fold(edge.to)) if (from === undefined || to === undefined) { dropped.push(`${rule.code}: ${edge.from} → ${edge.to}`) continue } if (from === to) continue const key = `${from} ${to}` if (seen.has(key)) continue seen.add(key) contributed = true transitions.push({ from, to, rule: rule.code, ...(edge.by !== undefined ? { by: edge.by } : {}), ...(edge.guard !== undefined ? { guard: edge.guard } : {}), }) } if (contributed && rule.errorCode !== undefined && rule.errorCode.trim() !== '') { errorCodes.add(rule.errorCode.trim()) } } return { transitions, dropped, errorCodes: [...errorCodes] } } /** * DERIVE core. Folds the section's SmartKanban screen into the LIST pagespec: * columns from the BA block (keys re-anchored VERBATIM on the entity enum), * transitions from the module's Flow rules, viewModes ∪ 'kanban', i18n column * labels seeded (fr copy in all 4 locales + needsTranslation report), and — * when the graph warrants DnD and a UC anchor exists — the canonical `move` * action. Writes ONLY on full determinism; ambiguity → `needs-judgment`. */ export function deriveKanbanForPagespec( md: string, path: string, ctx: KanbanDeriveContext, ): { md: string; outcome: KanbanPagespecOutcome } { 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 const view = typeof block.view === 'string' ? block.view : null if (view === 'kanban') { return { md, outcome: { path, entity, status: 'legacy-standalone', reason: 'standalone kanban pagespec (pre-fold shape) — the board is a representation of the LIST: fold the config into the list pagespec (this CLI derives it), then DELETE this file', }, } } if (view !== 'list') { return { md, outcome: { path, entity, status: 'skipped', reason: 'not a list view' } } } if (block.kanban !== undefined && block.kanban !== null) { return { md, outcome: { path, entity, status: 'already' } } } const config = ctx.kanbanScreen?.kanban if (config === undefined) { return { md, outcome: { path, entity, status: 'no-kanban-screen' } } } if (config.statusField === undefined) { return { md, outcome: { path, entity, status: 'needs-judgment', reason: `SmartKanban ${ctx.kanbanScreen!.code} has no « Champ statut » bullet — author it`, }, } } const statusCamel = toCamelFirst(config.statusField) const anchor = ctx.statusEnums.find((e) => toCamelFirst(e.name) === statusCamel) if (anchor === undefined) { return { md, outcome: { path, entity, status: 'needs-judgment', reason: `« Champ statut » '${config.statusField}' does not name an enum attribute of ${entity} in entité.md — fix the screen or the data model`, }, } } // Columns — re-anchored VERBATIM on the enum (fold match). Unknown → dropped. const byFold = new Map(anchor.values.map((v) => [fold(v), v])) const droppedColumns: string[] = [] const columns: Array> = [] const seenKeys = new Set() for (const col of config.columns) { const verbatim = byFold.get(fold(col.key)) if (verbatim === undefined) { droppedColumns.push(col.key) continue } if (seenKeys.has(verbatim)) continue seenKeys.add(verbatim) columns.push({ key: verbatim, labelKey: `kanban.columns.${verbatim}`, ...(col.color !== undefined && (KANBAN_BA_COLORS as readonly string[]).includes(col.color) ? { color: col.color } : {}), }) } if (columns.length < 2) { return { md, outcome: { path, entity, status: 'needs-judgment', reason: `columns of ${ctx.kanbanScreen!.code} don't map onto the ${anchor.name} enum (${anchor.values.join('/')})` + (droppedColumns.length > 0 ? ` — unmapped: ${droppedColumns.join(', ')}` : ''), }, } } // Transitions — the Flow graph projected onto the enum. Rules that exist but // map to ZERO edges = the label-vs-token mismatch class → fail closed. const graph = mapFlowOntoEnum(ctx.workflowRules, anchor.values) if (ctx.workflowRules.length > 0 && graph.transitions.length === 0) { return { md, outcome: { path, entity, status: 'needs-judgment', droppedTransitions: graph.dropped, reason: `the module's workflow rules (${ctx.workflowRules.map((r) => r.code).join(', ')}) carry Flow edges but NONE maps onto the ${anchor.name} enum — align the rule tokens with entité.md`, }, } } const kanbanBlock: Record = { statusField: statusCamel, columns, ...(config.titleField !== undefined ? { titleField: toCamelFirst(config.titleField) } : {}), ...(config.subtitleField !== undefined ? { subtitleField: toCamelFirst(config.subtitleField) } : {}), ...(config.cardFields.length > 0 ? { cardFields: config.cardFields.slice(0, 4).map(toCamelFirst) } : {}), ...(graph.transitions.length > 0 ? { transitions: graph.transitions } : {}), ...(graph.errorCodes.length === 1 ? { transitionErrorCode: graph.errorCodes[0] } : {}), } block.kanban = kanbanBlock // viewModes ∪ 'kanban', ordered by the canonical vocabulary. const existing = Array.isArray(block.viewModes) ? (block.viewModes as unknown[]).filter((v): v is string => typeof v === 'string') : ['table'] const union = new Set([...existing, 'table', 'kanban']) block.viewModes = LIST_VIEW_MODES.filter((m) => union.has(m)) if (block.defaultViewMode === undefined && !ctx.hasListScreen) { // Degenerate corpus: the section authored ONLY a board — open on it. block.defaultViewMode = 'kanban' } // i18n — column labels seeded in the 4 locales with the BA's fr copy; // en/it/de entries ride the needsTranslation channel (never a `[xx]`). const needsTranslation: string[] = [] const i18n = block.i18nKeys as Record> | undefined if (i18n !== null && typeof i18n === 'object') { const labelByKey = new Map(config.columns.map((c) => [fold(c.key), c.label])) for (const col of columns) { const key = col.labelKey as string const frLabel = labelByKey.get(fold(col.key as string)) ?? (col.key as string) for (const locale of LOCALES) { const map = i18n[locale] if (map === undefined || map === null || typeof map !== 'object') continue if (map[key] === undefined) { map[key] = frLabel if (locale !== 'fr' && !needsTranslation.includes(key)) needsTranslation.push(key) } } } } // The canonical move action — derived only when the graph warrants DnD, a // UC anchor exists and the page's own permission gives the update root. let derivedAction: string | undefined let moveActionSkipped: string | undefined if (graph.transitions.length > 0 && !hasMoveAction(block)) { const linkedUcs = Array.isArray(block.linkedUseCases) ? (block.linkedUseCases as unknown[]).filter((u): u is string => typeof u === 'string') : [] const permission = typeof block.permission === 'string' ? block.permission : '' const permParts = permission.split('.') if (linkedUcs.length === 0) { moveActionSkipped = 'no UC anchor (pagespec linkedUseCases is empty) — author the move action manually (UC: … on the screen.md action line)' } else if (permParts.length < 3) { moveActionSkipped = `pagespec permission '${permission}' is not the 3-4 segment grammar — cannot root the move permission` } else { const movePermission = [...permParts.slice(0, -1), 'update'].join('.') const actions = Array.isArray(block.actions) ? (block.actions as unknown[]) : [] actions.push({ code: KANBAN_MOVE_ACTION_CODE, kind: 'api', scope: 'row', endpoint: KANBAN_MOVE_ACTION_CODE, httpMethod: 'POST', payloadDto: null, responseDto: null, labelKey: 'list.actions.move', permission: movePermission, ucReference: linkedUcs[0], guardRules: [...new Set(graph.transitions.map((t) => t.rule).filter((r): r is string => r !== undefined))], payloadParameters: [ { name: statusCamel, type: 'select', required: true, field: statusCamel, // Without options a select param renders an EMPTY dropdown in the // table's CustomActionDialog — seed the columns (labels already in // i18n); the server matrix guard rejects invalid targets anyway. options: columns.map((c) => ({ value: c.key as string, labelKey: c.labelKey as string })), }, ], }) block.actions = actions derivedAction = KANBAN_MOVE_ACTION_CODE if (i18n !== null && typeof i18n === 'object') { for (const locale of LOCALES) { const map = i18n[locale] if (map !== undefined && map !== null && typeof map === 'object' && map['list.actions.move'] === undefined) { map['list.actions.move'] = MOVE_ACTION_LABELS[locale] } } } } } else if (graph.transitions.length === 0) { moveActionSkipped = 'no workflow rules with Flow edges — open matrix, no move action derived (author one to enable DnD)' } const md2 = replaceMachineBlock(md, raw, block) return { md: md2, outcome: { path, entity, status: 'derived', columns: columns.map((c) => c.key as string), ...(graph.transitions.length > 0 ? { transitions: graph.transitions.map((t) => ({ from: t.from, to: t.to, ...(t.rule !== undefined ? { rule: t.rule } : {}) })) } : {}), ...(graph.dropped.length > 0 ? { droppedTransitions: graph.dropped } : {}), ...(droppedColumns.length > 0 ? { reason: `columns outside the ${anchor.name} enum dropped: ${droppedColumns.join(', ')}` } : {}), ...(derivedAction !== undefined ? { derivedAction } : {}), ...(moveActionSkipped !== undefined ? { moveActionSkipped } : {}), ...(needsTranslation.length > 0 ? { needsTranslation } : {}), }, } } /** * CHECK core — the deterministic engine of PRD-135 (legs a-h, see types.ts). * Only pagespecs CARRYING a kanban block produce content findings; the two * structural gates (legacy standalone view, viewModes ∋ kanban without a * block) fire on their own shapes. */ export function checkKanbanOfPagespec( md: string, path: string, ctx: KanbanDeriveContext, ): KanbanFinding[] { const parsed = parseMachineBlock(md) if ('error' in parsed) return [] const { block } = parsed const view = typeof block.view === 'string' ? block.view : null const findings: KanbanFinding[] = [] if (view === 'kanban') { findings.push({ path, leg: 'f', severity: 'err', message: 'standalone `view: kanban` pagespec — the board is a representation of the LIST: run derive-kanban-spec (mode derive) on the list pagespec, then delete this file', }) return findings } const hasBlock = block.kanban !== undefined && block.kanban !== null const viewModes = Array.isArray(block.viewModes) ? (block.viewModes as unknown[]).filter((v): v is string => typeof v === 'string') : [] if (view !== 'list') { if (hasBlock) { findings.push({ path, leg: 'a', severity: 'err', message: `kanban block on a '${view}' pagespec — only a list view carries the board representation` }) } return findings } // (e) viewModes ⇔ block, both directions + defaultViewMode membership. if (hasBlock && !viewModes.includes('kanban')) { findings.push({ path, leg: 'e', severity: 'err', message: "kanban block present but viewModes does not include 'kanban' — the toggle never renders" }) } if (!hasBlock && viewModes.includes('kanban')) { findings.push({ path, leg: 'e', severity: 'err', message: "viewModes includes 'kanban' but no kanban block is authored — the board has no config" }) } const defaultViewMode = typeof block.defaultViewMode === 'string' ? block.defaultViewMode : undefined if (defaultViewMode !== undefined && viewModes.length > 0 && !viewModes.includes(defaultViewMode)) { findings.push({ path, leg: 'e', severity: 'err', message: `defaultViewMode '${defaultViewMode}' is not in viewModes [${viewModes.join(', ')}]` }) } if (!hasBlock) return findings const { kanban, rejected } = parsePageKanban(block.kanban) for (const issue of rejected) findings.push({ path, leg: 'schema', severity: 'err', message: issue }) if (!kanban) return findings const statusCamel = toCamelFirst(kanban.statusField) const anchor = ctx.statusEnums.find((e) => toCamelFirst(e.name) === statusCamel) // (b) columns anchored on the enum, verbatim. if (anchor === undefined) { findings.push({ path, leg: 'b', severity: 'err', message: `statusField '${kanban.statusField}' does not name an enum attribute of the entity in entité.md` }) } else { const values = new Set(anchor.values) for (const col of kanban.columns) { if (!values.has(col.key)) { findings.push({ path, leg: 'b', severity: 'err', message: `column '${col.key}' is not a verbatim value of ${anchor.name} (${anchor.values.join('/')})` }) } } const colKeys = new Set(kanban.columns.map((c) => c.key)) for (const v of anchor.values) { if (!colKeys.has(v)) { findings.push({ path, leg: 'b', severity: 'warn', message: `enum value '${v}' has no column — its rows land in the unassigned bucket` }) } } // (c) transitions ⊆ enum + parity with the BR Flow graph. for (const t of kanban.transitions ?? []) { for (const token of [t.from, t.to]) { if (!values.has(token)) { findings.push({ path, leg: 'c', severity: 'err', message: `transition token '${token}' is not a verbatim value of ${anchor.name}` }) } } } const graph = mapFlowOntoEnum(ctx.workflowRules, anchor.values) const blockEdges = new Set((kanban.transitions ?? []).map((t) => `${t.from} ${t.to}`)) for (const t of graph.transitions) { if (!blockEdges.has(`${t.from} ${t.to}`)) { findings.push({ path, leg: 'c', severity: 'err', message: `BR Flow edge ${t.from} → ${t.to} (${t.rule}) is not projected in kanban.transitions — regenerate (derive-kanban-spec)` }) } } const brEdges = new Set(graph.transitions.map((t) => `${t.from} ${t.to}`)) for (const t of kanban.transitions ?? []) { if (!brEdges.has(`${t.from} ${t.to}`)) { findings.push({ path, leg: 'c', severity: 'warn', message: `kanban transition ${t.from} → ${t.to} is backed by no workflow rule's Flow — the board is more permissive than the BR graph` }) } } } // (d) bucketability + card fields known to the list. const columnKeys = (Array.isArray(block.columns) ? (block.columns as Array<{ key?: unknown }>) : []) .map((c) => (c !== null && typeof c === 'object' ? c.key : undefined)) .filter((k): k is string => typeof k === 'string') .map(toCamelFirst) const fieldKeys = (Array.isArray(block.fields) ? (block.fields as Array<{ key?: unknown }>) : []) .map((f) => (f !== null && typeof f === 'object' ? f.key : undefined)) .filter((k): k is string => typeof k === 'string') .map(toCamelFirst) const known = new Set([...columnKeys, ...fieldKeys]) if (columnKeys.length > 0 && !columnKeys.includes(statusCamel)) { findings.push({ path, leg: 'd', severity: 'err', message: `statusField '${kanban.statusField}' is not among the list columns[] — the ListDto may not carry the bucket field` }) } if (known.size > 0) { for (const [slot, value] of [ ['titleField', kanban.titleField], ['subtitleField', kanban.subtitleField], ...(kanban.cardFields ?? []).map((f) => ['cardFields', f] as const), ] as Array<[string, string | undefined]>) { if (value !== undefined && !known.has(toCamelFirst(value))) { findings.push({ path, leg: 'd', severity: 'warn', message: `kanban.${slot} '${value}' is neither a list column nor a declared field` }) } } } // (g) authored graph without a move action = a board that cannot move cards. if ((kanban.transitions?.length ?? 0) > 0 && !hasMoveAction(block)) { findings.push({ path, leg: 'g', severity: 'warn', message: 'kanban.transitions authored but no `move` action on the pagespec — the board is read-only (DnD stays off)' }) } // (h) column labels seeded in fr. const i18n = block.i18nKeys as Record> | undefined const fr = i18n !== null && typeof i18n === 'object' ? i18n.fr : undefined if (fr !== undefined && fr !== null && typeof fr === 'object') { for (const col of kanban.columns) { if (fr[col.labelKey] === undefined) { findings.push({ path, leg: 'h', severity: 'err', message: `column label key '${col.labelKey}' is not authored in i18nKeys.fr` }) } } } return findings } /** Resolve the pagespec file list from the spec (explicit paths win). */ export function resolvePagespecPaths(spec: DeriveKanbanSpec, 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: reads entité.md + the module rules + the app screens * ONCE, builds a per-entity context, then derives/checks each pagespec. * `moduleRoot` is `//` — app and baRoot are derived from * its two parent segments (the ba tree layout, same as loadModuleRules). */ export function execute(spec: DeriveKanbanSpec, workdir: string): DeriveKanbanReport { const moduleRoot = isAbsolute(spec.moduleRoot) ? spec.moduleRoot : join(workdir, spec.moduleRoot) const module = basename(moduleRoot) const app = basename(dirname(moduleRoot)) const baRoot = dirname(dirname(moduleRoot)) const entitePath = join(moduleRoot, 'entité.md') const entiteMd = existsSync(entitePath) ? readFileSync(entitePath, 'utf-8') : '' const workflowRules = workflowRulesOf(loadModuleRules(baRoot, app, module).rules) const screens = loadScreens(baRoot, app).screens .filter((s) => s.module.toUpperCase() === module.toUpperCase()) const contextFor = (entity: string | null, section: string | null): KanbanDeriveContext => { const statusEnums = entity !== null && entiteMd !== '' ? parseEntityStatusEnums(entiteMd, entity) : [] const kanbans = screens.filter((s) => /kanban/i.test(s.screenType) && s.entity === entity) const kanbanScreen = kanbans.find((s) => section !== null && fold(s.section) === fold(section)) ?? [...kanbans].sort((a, b) => a.code.localeCompare(b.code))[0] const hasListScreen = screens.some( (s) => /listview/i.test(s.screenType) && s.entity === entity && (kanbanScreen === undefined || fold(s.section) === fold(kanbanScreen.section)), ) return { statusEnums, kanbanScreen, hasListScreen, workflowRules } } const outcomes: KanbanPagespecOutcome[] = [] const findings: KanbanFinding[] = [] 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 section = 'error' in parsed ? null : (typeof parsed.block.section === 'string' ? parsed.block.section : null) const ctx = contextFor(entity, section) if (spec.mode === 'check') { findings.push(...checkKanbanOfPagespec(md, path, ctx)) continue } const { md: md2, outcome } = deriveKanbanForPagespec(md, path, ctx) outcomes.push(outcome) if (outcome.status === 'derived' && md2 !== md) { writeFileSync(path, md2, 'utf-8') written++ } } return { outcomes, findings, written } }