/** * lib/pagespec-blocks.ts — THE grammar of a pagespec file on disk, shared by * every reader (audit-dev-api, audit-dev-frontend, audit-dev-actions-alignment, * lib/detail-tab-strip): the `..md` file name, the fenced * ```json block that carries the spec, and the tolerant custom-action loader * the two API-side audits used to carry as byte-identical copies. * * Audit 2026-09-24 (C-WARN-07 / C-WARN-20): the fenced-block regex was inlined * 6× in audit-dev-api and 10× in audit-dev-frontend, the file-name regex 4×, * and `loadPageActions` / `parsePagespecActions` existed twice — one grammar, * several drifting spellings. The file-name grammar and the loaders are the * historical form, moved, not rewritten. * * Lot 14 (2026-09-24): the FENCE is anchored — column-0 fences only. A block * opens on a line that is exactly ```json (trailing blanks allowed) and closes * on a line that is exactly ``` — the CommonMark reading, the one every real * pagespec in the corpus follows. The historical regex was unanchored: a prose * line ENDING with an inline ```json mention opened a phantom block, and an * INDENTED fence (a list item) opened one whose indented close never matched, * so the phantom ran to the next column-0 closer and swallowed the REAL block * after it — the FIRST-block reader then returned null for a file that had a * perfectly good block. `lib/__tests__/pagespec-blocks.test.ts` compares the * lib to an INDEPENDENT line scanner over the whole markdown corpus and asserts * EXACT agreement — parsed blocks, first block and raw fence boundaries — with * no exemption. CRLF: the capture never keeps the CR that precedes the closing * fence (interior CRs are JSON whitespace). * * Tolerant on purpose: an audit must still SEE a half-authored pagespec (a * missing `labelKey`, an unknown key) — the Zod gate on authoring is PRD-side * (audit-prd). A malformed JSON block is skipped here and flagged there. */ import path from 'node:path' import { findFiles, readText } from './fs.js' import { STANDARD_CRUD_CODES, defaultEndpoint } from './page-spec-actions.js' /** `..md` — applied to a basename or a path (unanchored on the left). */ export const PAGESPEC_FILENAME_RE = /([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/ /** `{ entity, view }` of a pagespec file name, or null when the name does not follow the convention. */ export function parsePagespecFilename(name: string): { entity: string; view: string } | null { const m = PAGESPEC_FILENAME_RE.exec(path.basename(name)) return m ? { entity: m[1], view: m[2] } : null } /** * The fenced block — NON-global, `m` flag, for a single `.exec(source)` (the * FIRST block). Column-0 fences on a line of their own (lot 14, 2026-09-24): * `^` / `$` are line anchors under `m`, `[ \t]*` tolerates trailing blanks on * either fence line, `\r?` before each `\n` keeps a CRLF source's trailing CR * out of the capture — and `$` under `m` stops before a CR too, which is what * makes a `\r\n`-terminated closer a closer. A ```json line INSIDE a block is * not a closer (`$` fails on the `j`), an indented or inline ```json is prose. */ export const FENCED_JSON_RE = /^```json[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m /** A FRESH global instance per call — a shared `/g` regex carries `lastIndex` across callers. Flags `gm`: the anchors stay line-wise. */ export function fencedJsonRe(): RegExp { return new RegExp(FENCED_JSON_RE.source, 'gm') } /** Every parsable fenced block of a source, in order; an unparsable block is skipped (the authoring gate's concern). */ export function extractJsonBlocks(source: string): unknown[] { const out: unknown[] = [] const re = fencedJsonRe() let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) { try { out.push(JSON.parse(m[1])) } catch { /* malformed — skipped here, flagged by audit-prd */ } } return out } /** The FIRST fenced block as an object, or null (absent, unparsable, or not an object). */ export function extractJsonBlock(source: string): Record | null { const m = FENCED_JSON_RE.exec(source) if (!m) return null try { const parsed: unknown = JSON.parse(m[1]) return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null } catch { return null } } export interface LoadedPagespec { abs: string /** Basename. */ file: string entity: string view: string /** The first fenced block, or null. */ spec: Record | null source: string } /** Every `pagespecs/*.md` directly under moduleDir whose name is `..md`. */ export async function loadPagespecs(moduleDir: string): Promise { const out: LoadedPagespec[] = [] for (const abs of await findFiles('pagespecs/*.md', { cwd: moduleDir })) { const name = parsePagespecFilename(abs) if (!name) continue const source = await readText(abs) if (!source) continue out.push({ abs, file: path.basename(abs), entity: name.entity, view: name.view, spec: extractJsonBlock(source), source }) } return out } // ─── Custom-action loader (moved VERBATIM from audit-dev-api; actions-alignment carried the same minus two fields) ─ /** A `kind: "api"` custom action of a pagespec, as the API-side audits consume it. */ export interface PageActionEntry { /** Pagespec basename. */ file: string entity: string module: string section: string code: string /** kebab-case URL segment — the source of truth. */ endpoint: string httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' scope: 'row' | 'bulk' | 'header' ucReference?: string /** C# request DTO name (null when the endpoint declares no body). */ payloadDto?: string | null /** True when the action declares collectible `payloadParameters[]` — the ONLY * thing that makes the generated UI render an input and POST a body. */ hasPayloadParameters: boolean } interface PageSpecShape { module?: string section?: string actions?: unknown[] } interface RawAction { code: string kind?: string scope?: string endpoint?: string httpMethod?: string ucReference?: string payloadDto?: string | null payloadParameters?: unknown[] } function isPageSpec(parsed: unknown): parsed is PageSpecShape { return typeof parsed === 'object' && parsed !== null } function isAction(action: unknown): action is RawAction { return typeof action === 'object' && action !== null && typeof (action as RawAction).code === 'string' } function isValidScope(scope: unknown): scope is PageActionEntry['scope'] { return scope === 'row' || scope === 'bulk' || scope === 'header' } /** Every `kind: "api"` custom action of every fenced block of a pagespec source (CRUD codes excluded). */ export function parsePagespecActions(source: string, entity: string, filename: string): PageActionEntry[] { const blocks: string[] = [] const re = fencedJsonRe() let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) blocks.push(m[1]) const entries: PageActionEntry[] = [] for (const block of blocks) { let parsed: unknown try { parsed = JSON.parse(block) } catch { continue } if (!isPageSpec(parsed)) continue const ps = parsed as PageSpecShape const actions = Array.isArray(ps.actions) ? ps.actions : [] for (const action of actions) { if (!isAction(action)) continue const kind = action.kind ?? 'api' if (kind !== 'api') continue if (STANDARD_CRUD_CODES.has(action.code)) continue const endpoint = action.endpoint ?? defaultEndpoint(action.code) const httpMethod = (action.httpMethod ?? 'POST').toUpperCase() as PageActionEntry['httpMethod'] const scope = action.scope if (!isValidScope(scope)) continue entries.push({ file: filename, entity, module: (ps.module ?? '').toLowerCase(), section: (ps.section ?? '').toLowerCase(), code: action.code, endpoint, httpMethod, scope, ucReference: action.ucReference, payloadDto: action.payloadDto ?? null, hasPayloadParameters: Array.isArray(action.payloadParameters) && action.payloadParameters.length > 0, }) } } return entries } /** Every custom action of every `pagespecs/*.md` directly under modulePath. */ export async function loadPageActions(modulePath: string): Promise { const out: PageActionEntry[] = [] for (const abs of await findFiles('pagespecs/*.md', { cwd: modulePath })) { const filename = path.basename(abs) const name = parsePagespecFilename(filename) if (!name) continue const source = await readText(abs) if (!source) continue out.push(...parsePagespecActions(source, name.entity, filename)) } return out }