/** * cli:derive-code-specs — derive.ts * * Pure core + a thin filesystem wrapper (the derive-filter-fks discipline). * Line parsing is NOT reimplemented here: `ENT_BLOCK_RE` / * `CODE_PATTERN_LINE_RE` / `parseCodePatternLine` / `validateFormat` come from * `lib/code-pattern-grammar` — the same contract DEV-API-022 and both * scaffolder halves read. Duplicating it is how the coded-seam class of bug * would come back. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { basename, dirname, join, relative } from 'node:path' import { CODE_PATTERN_LINE_RE, ENT_BLOCK_RE, findCodePatternNearMisses, hasSequence, parseCodePatternLine, referencedFields, validateFormat, } from '../../../lib/code-pattern-grammar.js' import { findCodeLikeAttributes, parseEntityDoc } from '../../../lib/ba-entities.js' import type { CodedEntityFlag } from '../../../lib/page-spec-coded-entity.js' import { toKebabCase } from '../../../lib/string-utils.js' import { extractFencedJson } from '../compute-page-diff/scan-pagespecs.js' import type { CodeLikeEntry, CodedEntityDerivation, DeriveCodeSpecsInput, DeriveCodeSpecsReport, NearMissEntry, PagespecFlagOutcome, } from './types.js' const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ /** Every entité.md under the module (module level + sections + resources). */ export function listEntiteDocs(moduleRoot: string): string[] { const out: string[] = [] const visit = (dir: string, depth: number): void => { if (depth > 3) return const direct = join(dir, 'entité.md') if (existsSync(direct) && statSync(direct).isFile()) out.push(direct) let names: string[] = [] try { names = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) } catch { return } for (const name of names) { if (name.startsWith('_') || name.startsWith('.') || name === 'pagespecs' || name === 'node_modules') continue visit(join(dir, name), depth + 1) } } visit(moduleRoot, 0) return out.sort() } /** Parse every `### ENT-… — Entity` block carrying a `**Code pattern**` line. */ export function collectCodedEntities( moduleRoot: string, applicationCode: string, moduleCode: string, only?: string, ): CodedEntityDerivation[] { const out: CodedEntityDerivation[] = [] for (const abs of listEntiteDocs(moduleRoot)) { const rel = relative(moduleRoot, abs).replace(/\\/g, '/') let current: string | null = null for (const rawLine of readFileSync(abs, 'utf-8').split(/\r?\n/)) { const h = ENT_BLOCK_RE.exec(rawLine) if (h) { current = h[1]; continue } if (!current) continue const cp = CODE_PATTERN_LINE_RE.exec(rawLine.trim()) if (!cp) continue if (only !== undefined && current !== only) continue const line = cp[1].trim() const parsed = parseCodePatternLine(line) const codeKey = `${applicationCode}.${toKebabCase(current)}` const reasons: string[] = [] if (parsed.format === null) { reasons.push( 'the line carries no backticked format mask — author it (`AFF-{YY}-{SEQ:4}` style); ' + 'without it neither scaffolder half can be derived and the seam stays unimplemented', ) } else { for (const problem of validateFormat(parsed.format, parsed.reset ?? 'None')) { reasons.push(`invalid format: ${problem}`) } } const usable = parsed.format !== null && reasons.length === 0 // The admin-screen descriptor label carries the business label when the // facet is authored (« Référence ») — else the historical `{Entity} codes`. const businessLabel = parsed.label !== null ? (parsed.label.fr ?? parsed.label.en) : null out.push({ entity: current, file: rel, line, codeKey, format: parsed.format, scaffoldEntityCoded: usable ? { codeKey, format: parsed.format! } : null, scaffoldCodedEntity: usable ? { entityName: current, applicationCode, module: moduleCode, codeKey, label: businessLabel !== null ? `${current} — ${businessLabel}` : `${current} codes`, defaultFormat: parsed.format!, // Unauthored facet → OMITTED: the scaffold-coded-entity Zod // defaults (Tenant / None / gapless true) apply downstream. ...(parsed.scope !== null ? { scopeKind: parsed.scope } : {}), ...(parsed.reset !== null ? { reset: parsed.reset } : {}), ...(parsed.gapless !== null ? { gapless: parsed.gapless } : {}), } : null, label: parsed.label, supplied: parsed.supplied, codeInputFields: parsed.format !== null ? referencedFields(parsed.format) : [], sequentialSuppliedRisk: parsed.supplied === true && parsed.format !== null && hasSequence(parsed.format), reasons, }) } } return out.sort((a, b) => a.entity.localeCompare(b.entity)) } /** Unparsable `Code pattern` mentions across every entité.md of the module — * the green-by-vacuity killer. Doc-level: never restricted by `entity`. */ export function collectNearMisses(moduleRoot: string): NearMissEntry[] { const out: NearMissEntry[] = [] for (const abs of listEntiteDocs(moduleRoot)) { const rel = relative(moduleRoot, abs).replace(/\\/g, '/') for (const miss of findCodePatternNearMisses(readFileSync(abs, 'utf-8'))) { out.push({ ...miss, file: rel }) } } return out } /** Unclassified code-like attributes (synonym lexicon) across the module, * with the near-miss co-signal joined per entity block. */ export function collectCodeLike(moduleRoot: string, nearMisses: NearMissEntry[]): CodeLikeEntry[] { const nearMissEntities = new Set( nearMisses.map((m) => m.entity).filter((e): e is string => e !== null), ) const out: CodeLikeEntry[] = [] for (const abs of listEntiteDocs(moduleRoot)) { const rel = relative(moduleRoot, abs).replace(/\\/g, '/') const { entities } = parseEntityDoc(readFileSync(abs, 'utf-8'), rel) for (const hit of findCodeLikeAttributes(entities)) { out.push({ ...hit, file: rel, coSignal: nearMissEntities.has(hit.entity) }) } } return out.sort((a, b) => a.entity.localeCompare(b.entity) || a.attribute.localeCompare(b.attribute)) } /** The module's pagespec files. */ export function listPagespecs(moduleRoot: string): string[] { const dir = join(moduleRoot, 'pagespecs') if (!existsSync(dir)) return [] return readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => join(dir, f)) .filter((p) => statSync(p).isFile()) .sort() } /** The pagespec-facing facets a coded entity derives from its entité.md line. */ export interface DerivedPagespecFacets { /** Enriched-object `label` slot (nulls omitted) — null = facet not authored. */ label: { fr?: string; en?: string } | null /** True only when the line declares `surchargeable à la création`. */ supplied: boolean /** Rides the flag WITH supplied so the pagespec is self-sufficient — the * create-only SmartCodeField mounts from the flag alone (VERBATIM * forwarding, no re-derivation in Phase 3a). null = not supplied/unusable. */ codeKey: string | null /** Fields the mask's derived tokens reference — SmartCodeField inputs. */ codeInputFields: string[] } export function toPagespecFacets( e: Pick, ): DerivedPagespecFacets { const label = e.label !== null ? { ...(e.label.fr !== null ? { fr: e.label.fr } : {}), ...(e.label.en !== null ? { en: e.label.en } : {}), } : null const supplied = e.supplied === true return { label: label !== null && Object.keys(label).length > 0 ? label : null, supplied, codeKey: supplied ? e.codeKey : null, codeInputFields: supplied ? e.codeInputFields : [], } } /** The flag value the pagespec SHOULD carry — merges onto the existing flag so * slots this deriver does not own always survive; an otherwise-empty object * collapses to the canonical boolean `true` (byte-stable common case). */ function desiredFlag(cur: unknown, facets: DerivedPagespecFacets): CodedEntityFlag { const base: Record = typeof cur === 'object' && cur !== null && !Array.isArray(cur) ? { ...cur } : {} if (facets.label !== null) base['label'] = facets.label else delete base['label'] if (facets.supplied) { base['supplied'] = true if (facets.codeKey !== null) base['codeKey'] = facets.codeKey else delete base['codeKey'] if (facets.codeInputFields.length > 0) base['codeInputFields'] = facets.codeInputFields else delete base['codeInputFields'] } else { delete base['supplied'] delete base['codeKey'] delete base['codeInputFields'] } return Object.keys(base).length === 0 ? true : (base as CodedEntityFlag) } /** * Reconcile ONE pagespec's `codedEntity` flag against the derived coded map. * Reads through `isCodedEntity` semantics (legacy boolean OR enriched object — * an object flag is never clobbered back to `true`), writes merge-preserving. * Returns the rewritten markdown (byte-identical when nothing changed) + the * outcome. */ export function reconcilePagespecFlag( md: string, path: string, codedEntities: ReadonlyMap, write: boolean, ): { md: string; outcome: PagespecFlagOutcome | null } { const json = extractFencedJson(md) if (json === null) return { md, outcome: null } let block: Record try { block = JSON.parse(json) as Record } catch { return { md, outcome: null } // malformed JSON is audit-prd's concern } const entity = typeof block.entity === 'string' ? block.entity : basename(path).split('.')[0]! const pagespec = basename(path) const facets = codedEntities.get(entity) const cur = block.codedEntity const hasFlag = cur === true || (typeof cur === 'object' && cur !== null && !Array.isArray(cur)) const rewrite = (value: CodedEntityFlag | undefined): string => { if (value === undefined) delete block.codedEntity else block.codedEntity = value const m = JSON_BLOCK_RE.exec(md)! return md.slice(0, m.index) + '```json\n' + JSON.stringify(block, null, 2) + '\n```' + md.slice(m.index + m[0].length) } if (facets === undefined) { // Not a coded entity — a present flag is stale (the upstream line died, // the object dies with it: entité.md is the single source of truth). if (!hasFlag) return { md, outcome: null } if (!write) return { md, outcome: { pagespec, entity, status: 'stale' } } return { md: rewrite(undefined), outcome: { pagespec, entity, status: 'stale-removed' } } } const desired = desiredFlag(cur, facets) if (!hasFlag) { if (!write) return { md, outcome: { pagespec, entity, status: 'missing' } } return { md: rewrite(desired), outcome: { pagespec, entity, status: 'added' } } } // Flag present on a coded entity — compare ONLY the slots this deriver owns // (codeKey/codeInputFields ride the `supplied` facet). const curObj = typeof cur === 'object' && cur !== null ? (cur as Record) : null const diverged: Array<'label' | 'supplied'> = [] if (JSON.stringify(curObj?.['label']) !== JSON.stringify(facets.label ?? undefined)) diverged.push('label') const suppliedDiff = (curObj?.['supplied'] === true) !== facets.supplied || JSON.stringify(curObj?.['codeKey']) !== JSON.stringify(facets.codeKey ?? undefined) || JSON.stringify(curObj?.['codeInputFields']) !== JSON.stringify(facets.codeInputFields.length > 0 ? facets.codeInputFields : undefined) if (suppliedDiff) diverged.push('supplied') if (diverged.length === 0) return { md, outcome: { pagespec, entity, status: 'already' } } if (!write) return { md, outcome: { pagespec, entity, status: 'facet-drift', facets: diverged } } return { md: rewrite(desired), outcome: { pagespec, entity, status: 'facet-healed', facets: diverged } } } /** Filesystem wrapper. */ export function deriveCodeSpecs(input: DeriveCodeSpecsInput): DeriveCodeSpecsReport { const appFolder = basename(dirname(input.moduleRoot)) const applicationCode = input.applicationCode ?? toKebabCase(appFolder) const moduleCode = toKebabCase(basename(input.moduleRoot)) const warnings: string[] = [] const entities = collectCodedEntities(input.moduleRoot, applicationCode, moduleCode, input.entity) // An entity whose line is unusable still COUNTS as coded for the pagespec // flag (the BA intent is authored — the mask is what's broken): flagging it // keeps the frontend guard armed while the line gets fixed. const codedMap = new Map(entities.map((e) => [e.entity, toPagespecFacets(e)])) // Doc-level signals — computed on the WHOLE module (never `entity`-scoped: // a near-miss has no reliable entity attribution to filter on). const nearMisses = collectNearMisses(input.moduleRoot) const codeLike = collectCodeLike(input.moduleRoot, nearMisses) const pagespecs: PagespecFlagOutcome[] = [] const drift: PagespecFlagOutcome[] = [] let added = 0 let staleRemoved = 0 for (const path of listPagespecs(input.moduleRoot)) { if (input.entity !== undefined && basename(path).split('.')[0] !== input.entity) continue const md = readFileSync(path, 'utf-8') const { md: md2, outcome } = reconcilePagespecFlag(md, path, codedMap, input.mode === 'derive') if (outcome === null) continue pagespecs.push(outcome) if (outcome.status === 'added') { added++; } if (outcome.status === 'stale-removed') { staleRemoved++; } if (outcome.status === 'missing' || outcome.status === 'stale' || outcome.status === 'facet-drift') { drift.push(outcome) } if (input.mode === 'derive' && md2 !== md) writeFileSync(path, md2, 'utf-8') } const blocked = entities.filter((e) => e.reasons.length > 0).length if (blocked > 0) { warnings.push( `${blocked} coded entit${blocked === 1 ? 'y' : 'ies'} with an unusable **Code pattern** line — ` + 'fix entité.md (the scaffolder halves cannot be derived until then): ' + entities.filter((e) => e.reasons.length > 0).map((e) => `${e.entity} (${e.reasons.join(' ; ')})`).join(' | '), ) } if (nearMisses.length > 0) { warnings.push( `${nearMisses.length} unparsable Code pattern mention(s) in entité.md — the chain derives NOTHING ` + 'from them (the green-by-vacuity incident shape): ' + nearMisses.map((m) => `${m.file}:${m.line} [${m.shape}]${m.entity !== null ? ` (${m.entity})` : ''}`).join(' | '), ) } for (const e of entities.filter((x) => x.sequentialSuppliedRisk)) { warnings.push( `${e.entity}: supplied + {SEQ} — a manually supplied code inside the sequence's FUTURE corridor is ` + 'not detected by EnsureAvailableAsync (the sequential allocator never consults the probe before ' + 'allocating); the collision surfaces LATER as a unique-index violation when the sequence reaches ' + "it. Keep supplied codes OUT of the mask's shape (different prefix/format), reserve them to " + 'imports/reprises, or use a derived (no-{SEQ}) format.', ) } return { moduleRoot: input.moduleRoot, entities, pagespecs, drift, nearMisses, codeLike, totals: { codedEntities: entities.length, pagespecs: pagespecs.length, added, staleRemoved, drift: drift.length, blocked, nearMisses: nearMisses.length, codeLike: codeLike.length, }, warnings, } }