/** * pagespec-blocks.test.ts — ONE grammar for a pagespec on disk. * * Audit 2026-09-24 (C-WARN-07 / C-WARN-20): the fenced-block regex lived as * 16 literals across audit-dev-api / audit-dev-frontend, the file-name regex * as 4 (one of them anchored), and the custom-action loader twice. The corpus * block below pins the shared grammar over the whole markdown corpus of the * skills tree. * * QC 2026-09-24 (lot 13): that corpus "parity" compared the shared regex to a * literal whose `.source` equality was asserted first — a regex compared to * itself. The oracle became an INDEPENDENT line scanner (scanFencedJson): * column-0 fences on a line of their own, CR stripped. The lib was still the * unanchored regex, so lot 13 had to NAME the 3 corpus files where the two * grammars drew different fence boundaries (an inline ```json at the end of a * prose line, an indented fence in a list item) and exempt the FIRST-block * reader on them. * * Lot 14 (2026-09-24): the lib is aligned on the scanner — column-0 fences on a * line of their own, `m` flag, a CRLF capture without its trailing CR. Nothing * is exempt any more: KNOWN_DIVERGENT is EMPTY and the corpus block asserts * EXACT agreement on every file — extractJsonBlocks(), the FIRST-block reader * AND the raw fence boundaries. The historical unanchored regex stays in this * file as OLD_UNANCHORED_RE so the FIRES cases keep showing what the old * grammar did on the same inputs, and so the corpus is proven to still contain * the shapes that used to diverge (a corpus that lost them would prove nothing). * The SILENT side runs a REAL SmartStack writer of the fence * (renamePagespecRefs, lib/ba-menu-tree) and reads its output back. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { findFiles } from '../fs.js' import { renamePagespecRefs } from '../ba-menu-tree.js' import { FENCED_JSON_RE, extractJsonBlock, extractJsonBlocks, fencedJsonRe, loadPageActions, loadPagespecs, parsePagespecActions, parsePagespecFilename, } from '../pagespec-blocks.js' import { entityViewsFromPagespecFilenames } from '../detail-tab-strip.js' const SKILLS_ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '..', '..') /** * The grammar the lib carried until lot 14 — kept HERE, as a literal, so the * FIRES cases show what it did on the same inputs. Unanchored: it opened on * any ```json followed by a newline, wherever that stood on its line, and it * closed on any `\n```` — a column-0 opener of the NEXT block included. */ const OLD_UNANCHORED_RE = /```json\s*\n([\s\S]*?)\n```/g /** Every capture of a /g regex over a source. */ function allBlocks(source: string, re: RegExp): string[] { const out: string[] = [] re.lastIndex = 0 let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) out.push(m[1]) return out } /** * The INDEPENDENT oracle — a line scanner, not a regex: a line `^```json\s*$` * opens a block, the next line `^```\s*$` closes it, the body is the lines * between joined with `\n`, CR stripped. A fence sits at column 0 on a line of * its own — the CommonMark reading of every real pagespec in this corpus. */ function scanFencedJson(source: string): string[] { const out: string[] = [] let body: string[] | null = null for (const rawLine of source.split('\n')) { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine if (body === null) { if (/^```json\s*$/.test(line)) body = [] } else if (/^```\s*$/.test(line)) { out.push(body.join('\n')) body = null } else { body.push(line) } } return out } const tryParse = (s: string): unknown => { try { return JSON.parse(s) } catch { return undefined } } const asObject = (v: unknown): Record | null => v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null /** * The lib keeps a CRLF source's INTERIOR CRs (JSON whitespace); the scanner works * on CR-stripped lines. Folding CRLF→LF on the lib side is the ONLY normalisation * of the raw-boundary comparison — a CR left before the closer (or a lone one) * survives the fold and breaks equality. */ const crlfToLf = (body: string): string => body.replace(/\r\n/g, '\n') /** Lot 13's tolerant form, used ONLY to measure the OLD grammar: CR stripped, the blank lines its `\s*` swallowed after the opener dropped. */ const legacyForm = (body: string): string => body.replace(/\r/g, '').replace(/^(?:[ \t]*\n)+/, '') /** A prose line that ENDS with an inline ```json mention (the audit-prd/SKILL.md shape). */ const INLINE_EOL_MENTION_RE = /\S[^\n]*```json[ \t]*\r?$/m /** An INDENTED ```json fence — a list item's code block (the create-prd / fix-bug SKILL.md shape). */ const INDENTED_OPENER_RE = /^[ \t]+```json[ \t]*\r?$/m /** * Files where the lib and the scanner draw DIFFERENT fence boundaries. Lot 13 * named three (audit-prd, create-prd, fix-bug — the unanchored regex opened a * phantom on an inline or indented fence). Lot 14 anchored the lib: the list * is EMPTY and must stay so — a new divergence is red, with no list to join. */ const KNOWN_DIVERGENT: ReadonlyArray<{ file: string; reason: string }> = [] /** The spec create-prd would author for a list page, as the screen-controller's own tests spell it. */ const AUTHORED_SPEC = { screenCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-001', appCode: 'crm', module: 'pipeline', section: 'opportunites', entity: 'Opportunity', view: 'list', permission: 'pipeline.opportunites.read', columns: [], actions: [], } /** * A pagespec as create-prd lays it out — machine block first, human body after * — passed through renamePagespecRefs (lib/ba-menu-tree), a REAL SmartStack * writer that re-serialises the block at column 0: the SILENT side of the * lot-14 cases. The body carries the two prose shapes the old grammar mistook * for fences; the expected spec is derived from the rename, not from the lib. */ function emitRenamedPagespec(): { content: string; spec: Record } { const authored = [ '# Opportunity.list', '', '```json', JSON.stringify(AUTHORED_SPEC, null, 2), '```', '', '## Notes', '', '1. The machine block above is the fenced ```json', ' ```json', ' { "example": "an indented illustration, not the spec" }', ' ```', '', ].join('\n') const out = renamePagespecRefs(authored, { app: 'crm', mod: 'pipeline', oldSec: 'opportunites', newSec: 'deals', ownModule: true }) if (out.error !== undefined || out.count === 0) throw new Error(`renamePagespecRefs did not rewrite: ${JSON.stringify(out)}`) return { content: out.content, spec: { ...AUTHORED_SPEC, section: 'deals', permission: 'pipeline.deals.read' } } } describe('corpus — the shared grammar agrees EXACTLY with an INDEPENDENT line scanner', () => { it('the oracle is independent: the historical unanchored grammar disagrees with it on an indented or inline fence — the anchored lib sides with the scanner', () => { const indented = 'text\n ```json\n {"a":1}\n ```\nmore\n```\n' expect(allBlocks(indented, OLD_UNANCHORED_RE)).toHaveLength(1) // opened at the indented fence, closed at the column-0 one expect(scanFencedJson(indented)).toEqual([]) expect(allBlocks(indented, fencedJsonRe())).toEqual([]) const inline = 'each a fenced ```json\nblock\n```\n' expect(allBlocks(inline, OLD_UNANCHORED_RE)).toHaveLength(1) expect(scanFencedJson(inline)).toEqual([]) expect(allBlocks(inline, fencedJsonRe())).toEqual([]) // On a well-formed document all three find the block, CRLF included — and the lib's capture is the scanner's, byte for byte; the old one kept the CR. const ok = '# Doc\r\n\r\n```json\r\n{"a":1}\r\n```\r\n' expect(scanFencedJson(ok)).toEqual(['{"a":1}']) expect(allBlocks(ok, fencedJsonRe())).toEqual(['{"a":1}']) expect(allBlocks(ok, OLD_UNANCHORED_RE)).toEqual(['{"a":1}\r']) }) it('every markdown file of the skills tree yields EXACTLY the scanner\'s blocks — parsed, first, and raw boundaries — with no exemption', async () => { const files = await findFiles('**/*.md', { cwd: SKILLS_ROOT }) let withBlocks = 0 let blocks = 0 let inlineEolFiles = 0 let indentedFiles = 0 const divergent: string[] = [] const oldDivergent: string[] = [] for (const abs of files) { const rel = path.relative(SKILLS_ROOT, abs).replace(/\\/g, '/') const source = readFileSync(abs, 'utf-8') const scanned = scanFencedJson(source) // The consumer-facing readers — on EVERY file. expect(extractJsonBlocks(source), rel).toEqual(scanned.map(tryParse).filter((v) => v !== undefined)) expect(extractJsonBlock(source), rel).toEqual(scanned.length > 0 ? asObject(tryParse(scanned[0])) : null) // The raw boundaries — exact, CRLF folded on the lib side only; a trailing CR would survive the fold. const libBodies = allBlocks(source, fencedJsonRe()) for (const body of libBodies) expect(body.endsWith('\r'), `${rel}: a capture keeps the CR before its closer`).toBe(false) const libLf = libBodies.map(crlfToLf) if (libLf.length !== scanned.length || libLf.some((b, i) => b !== scanned[i])) divergent.push(rel) // What the OLD grammar did on the same file, measured the tolerant lot-13 way. const oldBodies = allBlocks(source, OLD_UNANCHORED_RE).map(legacyForm) const scannedLegacy = scanned.map(legacyForm) if (oldBodies.length !== scannedLegacy.length || oldBodies.some((b, i) => b !== scannedLegacy[i])) oldDivergent.push(rel) if (INLINE_EOL_MENTION_RE.test(source)) inlineEolFiles++ if (INDENTED_OPENER_RE.test(source)) indentedFiles++ if (scanned.length > 0) withBlocks++ blocks += scanned.length } // The corpus is not empty — a green run on zero files would prove nothing. expect(withBlocks).toBeGreaterThan(20) expect(blocks).toBeGreaterThan(40) // The boundaries agree on EVERY file — there is no list of exemptions left. expect(divergent.sort()).toEqual(KNOWN_DIVERGENT.map((k) => k.file).sort()) expect(KNOWN_DIVERGENT).toEqual([]) // The corpus still carries the shapes the old grammar got wrong — an inline ```json ending a prose // line, an indented fence — and the old grammar still diverges on it: the lot-14 fix is not vacuous here. expect(inlineEolFiles).toBeGreaterThan(0) expect(indentedFiles).toBeGreaterThan(0) expect(oldDivergent.length).toBeGreaterThan(0) // A corpus-wide walk (every SKILL.md of the tree, three grammars per file) sits at the // 5 s default on a loaded host and grows with every skill — the budget is the corpus's, not vitest's. }, 60_000) it('a fresh /gm instance per call — one caller\'s lastIndex never leaks into another', () => { expect(FENCED_JSON_RE.flags).toBe('m') expect(fencedJsonRe().flags).toBe('gm') const source = '```json\n{"a":1}\n```\n\n```json\n{"b":2}\n```\n' const first = fencedJsonRe() expect(first.exec(source)?.[1]).toBe('{"a":1}') // A second caller starts from 0, whatever the first one did. expect(fencedJsonRe().exec(source)?.[1]).toBe('{"a":1}') expect(first.exec(source)?.[1]).toBe('{"b":2}') }) }) describe('parsePagespecFilename / PAGESPEC_FILENAME_RE', () => { it('reads ..md from a basename or a path', () => { expect(parsePagespecFilename('Client.list.md')).toEqual({ entity: 'Client', view: 'list' }) expect(parsePagespecFilename('Invoice.detail.md')).toEqual({ entity: 'Invoice', view: 'detail' }) expect(parsePagespecFilename('OrderLine.form.md')).toEqual({ entity: 'OrderLine', view: 'form' }) expect(parsePagespecFilename('App.app-home.md')).toEqual({ entity: 'App', view: 'app-home' }) expect(parsePagespecFilename('/x/ba/crm/pagespecs/Client.list.md')).toEqual({ entity: 'Client', view: 'list' }) }) it('refuses names that do not follow the convention', () => { expect(parsePagespecFilename('client.list.md')).toBeNull() // lowercase entity expect(parsePagespecFilename('Client.List.md')).toBeNull() // uppercase view expect(parsePagespecFilename('Client.md')).toBeNull() // no view expect(parsePagespecFilename('notes.md')).toBeNull() expect(parsePagespecFilename('Client.list.json')).toBeNull() }) it('lib/detail-tab-strip reads the SAME grammar (its private anchored copy is gone)', () => { const map = entityViewsFromPagespecFilenames(['Client.list.md', 'Client.detail.md', 'notes.md']) expect([...map.get('Client') ?? []].sort()).toEqual(['detail', 'list']) expect(map.has('notes')).toBe(false) // The unanchored form (what 3 of the 4 readers already used) is the reconciled grammar. expect(entityViewsFromPagespecFilenames(['x/Client.form.md']).get('Client')).toEqual(new Set(['form'])) }) }) describe('extractJsonBlock(s) — the anchored fence (lot 14)', () => { const TWO_BLOCKS = '# Doc\n\n```json\n{ "entity": "A" }\n```\n\ntext\n\n```json\n[1, 2]\n```\n\n```json\n{ not json\n```\n' it('extractJsonBlocks returns every PARSABLE block in order, skipping a malformed one', () => { expect(extractJsonBlocks(TWO_BLOCKS)).toEqual([{ entity: 'A' }, [1, 2]]) }) it('extractJsonBlock returns the FIRST block as an object, null otherwise', () => { expect(extractJsonBlock(TWO_BLOCKS)).toEqual({ entity: 'A' }) expect(extractJsonBlock('```json\n[1]\n```')).toBeNull() // not an object expect(extractJsonBlock('```json\n{ nope\n```')).toBeNull() // unparsable expect(extractJsonBlock('no block here')).toBeNull() }) it('tolerates trailing blanks after either fence and CRLF endings', () => { expect(extractJsonBlock('```json \n{"a":1}\n```')).toEqual({ a: 1 }) expect(extractJsonBlock('```json\r\n{"a":1}\r\n```')).toEqual({ a: 1 }) expect(extractJsonBlock('```json\t\n{"a":1}\n``` \n')).toEqual({ a: 1 }) }) it('FIRES — a prose line ENDING with an inline ```json mention opens nothing; the old grammar opened a phantom there that hid the real block (audit-prd/SKILL.md shape)', () => { const doc = '- each a fenced ```json\n block, one per screen.\n\n```json\n{"a":1}\n```\n' // Old: ONE block, the phantom from the mention to the real block's OPENER — unparsable, so the first-block reader returned null. expect(allBlocks(doc, OLD_UNANCHORED_RE).map(tryParse)).toEqual([undefined]) expect(extractJsonBlock(doc)).toEqual({ a: 1 }) expect(extractJsonBlocks(doc)).toEqual([{ a: 1 }]) // The mention followed by what looks like a body: still prose. expect(extractJsonBlock('each a fenced ```json\n{"a":1}\n```\n')).toBeNull() expect(extractJsonBlocks('each a fenced ```json\n{"a":1}\n```\n')).toEqual([]) }) it('FIRES — an indented fence is prose: it no longer swallows the real block after it (fix-bug / create-prd SKILL.md shape)', () => { const doc = '1. Emit:\n ```json\n {"phantom":true}\n ```\n\nThe real one:\n\n```json\n{"real":true}\n```\n' // Old: opened at the indented fence, its indented closer never matched, the phantom ran to the column-0 opener of the real block. expect(allBlocks(doc, OLD_UNANCHORED_RE).map(tryParse)).toEqual([undefined]) expect(extractJsonBlock(doc)).toEqual({ real: true }) expect(extractJsonBlocks(doc)).toEqual([{ real: true }]) expect(extractJsonBlocks(' ```json\n {"x":1}\n ```\n')).toEqual([]) }) it('the closer is a bare ``` at column 0 on a line of its own — anything else keeps the block open', () => { expect(extractJsonBlock('```json\n{"a":1}\n``` trailing\n')).toBeNull() // text after the closer expect(extractJsonBlock('```json\n{"a":1}```\n')).toBeNull() // closer not at column 0 expect(extractJsonBlock('```json\n{"a":1}\n ```\n')).toBeNull() // indented closer expect(extractJsonBlock('```json\n{"a":1}\n``` trailing\n```\n')).toBeNull() // …the block then runs to the real closer, unparsable expect(extractJsonBlocks('```json\n{"a":1}\n```json\n```\n')).toEqual([]) // a ```json line inside a block is not a closer }) it('CRLF — the capture keeps the interior CRs (JSON whitespace) and never the one before the closer', () => { const doc = '```json\r\n{\r\n "a": 1\r\n}\r\n```\r\n\r\n```json\r\n{"b":2}\r\n```' expect(FENCED_JSON_RE.exec(doc)?.[1]).toBe('{\r\n "a": 1\r\n}') expect(allBlocks(doc, fencedJsonRe())).toEqual(['{\r\n "a": 1\r\n}', '{"b":2}']) expect(extractJsonBlocks(doc)).toEqual([{ a: 1 }, { b: 2 }]) // Mixed endings: a CRLF opener line, an LF closer — one block, no CR in its tail. expect(FENCED_JSON_RE.exec('```json\r\n{"a":1}\n```')?.[1]).toBe('{"a":1}') }) it('a blank line after the opener stays in the capture (JSON whitespace) — the old `\\s*` swallowed it', () => { expect(FENCED_JSON_RE.exec('```json\n\n{"a":1}\n```')?.[1]).toBe('\n{"a":1}') expect(extractJsonBlock('```json\n\n{"a":1}\n```')).toEqual({ a: 1 }) }) it('SILENT — the fence a real SmartStack writer emits is read back as written: renamePagespecRefs re-serialises the block, the prose shapes below it open nothing', () => { const { content, spec } = emitRenamedPagespec() expect(extractJsonBlock(content)).toEqual(spec) // Exactly ONE block — the writer's; the inline mention and the indented illustration in the human body are prose. expect(extractJsonBlocks(content)).toEqual([spec]) // The capture IS the writer's serialisation, byte for byte. expect(FENCED_JSON_RE.exec(content)?.[1]).toBe(JSON.stringify(spec, null, 2)) }) }) describe('loadPagespecs / parsePagespecActions / loadPageActions', () => { let root: string beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'ss-pagespec-blocks-')) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function write(rel: string, content: string): void { const abs = path.join(root, rel) mkdirSync(path.dirname(abs), { recursive: true }) writeFileSync(abs, content, 'utf-8') } const LIST_WITH_ACTIONS = `# Client.list \`\`\`json { "module": "CRM", "section": "Clients", "entity": "Client", "view": "list", "actions": [ { "code": "create", "scope": "header" }, { "code": "export", "scope": "header", "httpMethod": "get" }, { "code": "archive", "scope": "row", "endpoint": "archive-now", "ucReference": "UC-003", "payloadDto": "ArchiveClientRequest", "payloadParameters": [{ "key": "reason" }] }, { "code": "open", "scope": "row", "kind": "navigate", "targetRoute": "/x" }, { "code": "weird", "scope": "everywhere" }, "not-an-action" ] } \`\`\` ` it('loadPagespecs lists every ..md of pagespecs/ with its first block', async () => { write('pagespecs/Client.list.md', LIST_WITH_ACTIONS) write('pagespecs/Client.detail.md', '# Client.detail\n\n```json\n{ "entity": "Client", "view": "detail" }\n```\n') write('pagespecs/notes.md', '# not a pagespec\n') write('pagespecs/Broken.form.md', '# no block at all\n') const specs = await loadPagespecs(root) const byFile = new Map(specs.map((s) => [s.file, s])) expect([...byFile.keys()].sort()).toEqual(['Broken.form.md', 'Client.detail.md', 'Client.list.md']) expect(byFile.get('Client.list.md')?.entity).toBe('Client') expect(byFile.get('Client.list.md')?.view).toBe('list') expect(byFile.get('Client.list.md')?.spec?.module).toBe('CRM') expect(byFile.get('Client.detail.md')?.spec).toEqual({ entity: 'Client', view: 'detail' }) expect(byFile.get('Broken.form.md')?.spec).toBeNull() expect(byFile.get('Client.list.md')?.abs).toBe(path.join(root, 'pagespecs', 'Client.list.md')) }) it('SILENT — a pagespec re-serialised by renamePagespecRefs (a real SmartStack writer) is loaded with the block it wrote, CRLF on disk included', async () => { const { content, spec } = emitRenamedPagespec() write('pagespecs/Opportunity.list.md', content) write('pagespecs/Opportunity.detail.md', content.replace(/\n/g, '\r\n')) // the same file after a CRLF checkout const specs = await loadPagespecs(root) expect(specs.map((s) => s.file).sort()).toEqual(['Opportunity.detail.md', 'Opportunity.list.md']) for (const loaded of specs) expect(loaded.spec, loaded.file).toEqual(spec) }) it('parsePagespecActions keeps the kind:"api" custom actions only, with the historical defaults', () => { const entries = parsePagespecActions(LIST_WITH_ACTIONS, 'Client', 'Client.list.md') expect(entries.map((e) => e.code)).toEqual(['export', 'archive']) const [exp, archive] = entries // CRUD (`create`) excluded, navigate excluded, invalid scope skipped, non-object skipped. expect(exp).toMatchObject({ file: 'Client.list.md', entity: 'Client', module: 'crm', section: 'clients', endpoint: 'export', httpMethod: 'GET', scope: 'header', payloadDto: null, hasPayloadParameters: false, }) expect(exp.ucReference).toBeUndefined() expect(archive).toMatchObject({ endpoint: 'archive-now', httpMethod: 'POST', scope: 'row', ucReference: 'UC-003', payloadDto: 'ArchiveClientRequest', hasPayloadParameters: true, }) }) it('a malformed block is skipped and the next block is still read', () => { const source = '```json\n{ broken\n```\n\n```json\n{ "actions": [ { "code": "ping", "scope": "bulk" } ] }\n```\n' const entries = parsePagespecActions(source, 'X', 'X.list.md') expect(entries).toHaveLength(1) expect(entries[0]).toMatchObject({ code: 'ping', endpoint: 'ping', httpMethod: 'POST', scope: 'bulk', module: '', section: '' }) }) it('loadPageActions walks pagespecs/*.md and ignores files outside the naming convention', async () => { write('pagespecs/Client.list.md', LIST_WITH_ACTIONS) write('pagespecs/notes.md', '```json\n{ "actions": [ { "code": "ghost", "scope": "row" } ] }\n```\n') const entries = await loadPageActions(root) expect(entries.map((e) => `${e.file}:${e.code}`).sort()).toEqual(['Client.list.md:archive', 'Client.list.md:export']) }) })