Files
msd-core/tests/markdown-table.test.cjs
Tom Boucher d49ac81306 chore(#2143): markdown table model + schema registry + fail-loud pilot — Phase 1 (#2248)
* chore(#2143): markdown table model + schema registry + fail-loud pilot — Phase 1

Phase 1 of epic #2143 (ADR-2143): consolidate markdown table parsing onto a
canonical seam and migrate the pilot reader.

- Add src/markdown-table.cts: parseMarkdownTable (GFM tables -> typed
  {columns, rows} addressed by column NAME; ragged rows are typed parse
  errors, not silent), a single-source TABLE_SCHEMAS registry
  (RoadmapProgress / RequirementsTraceability / QuickTasks / Security, with
  variants under one id), matchTableSchema, and findTableBySchema. Result<T>
  is scoped to this seam (distinct from the dispatch Result).
- Migrate deriveProgressFromRoadmap (src/phase-lifecycle.cts) off the
  position-anchored regex to name-based resolution via the seam — fixes #2137
  (the 5-column milestone-grouped Progress table previously returned all-null).
- Add a schema-backed `gsd-tools quick-tasks-append` subcommand and route
  fast.md's log_to_state through it, retiring the inline `awk NF-2` column
  arithmetic — fixes #2133 (addresses #2012, #2119). Cell values are escaped
  (| and newlines) and the STATE.md read-modify-write is atomic under
  readModifyWriteStateMd (lost-update race, cf. #500/#905/#1230).
- Writer/reader/template parity test guards TABLE_SCHEMAS against drift
  (ADR-2143 §3 Generative-Fix-Divergence).

Registration: .gitignore, eslint.config.mjs, docs/INVENTORY.md +
INVENTORY-MANIFEST.json, CONTEXT.md glossary, docs/CLI-TOOLS.md.

Behaviour-preserving for the canonical 4-column Progress table; the named
bugs are driven fail-first. Extend-never-mutate (ADR-2143 §2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#2242): backfill changeset PR number (#2248)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2242): escape backslash before pipe in markdown-table cell escaping

CodeQL js/incomplete-sanitization (high): escapeCell escaped | -> \| but not
the backslash itself. Now escapes \ -> \\ before | -> \|, and splitTableRow
unescapes both \\ -> \ and \| -> | symmetrically so cell values (incl.
literal backslashes) round-trip exactly. Added backslash round-trip tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2242): read ROADMAP Progress table by column name — supersede #2168 ad-hoc scan

Rebase reconciliation with #2168 (the tactical #2137 fix that marked itself
"pending #2143"). deriveProgressFromRoadmap now resolves the Progress table via
a new seam helper findTableWithColumns (first table whose header is a superset of
Phase/Plans Complete/Status/Completed, any order, extra columns ignored) and reads
cells by NAME — order/injection-invariant per ADR-2143 §3 — instead of the exact
TABLE_SCHEMAS match. This satisfies #2168's column-invariance property test while
staying seam-based and preserving its `## Progress` scoping (#2012/#1445).
Ragged Progress tables now resolve to null (ADR-2143 fail-loud); updated the stale
state.test.cjs assertion that predated the Phase-1 migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:36:14 -04:00

614 lines
24 KiB
JavaScript

'use strict';
/**
* Behavioral tests for markdown-table.cjs
*
* Module: gsd-core/bin/lib/markdown-table.cjs
* Exports: parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS
*
* Covers:
* - parseMarkdownTable happy path (4-col + 5-col headers, cells addressed by name)
* - BOUNDARY coverage: ragged data rows at limit-1 / limit / limit+1 cell counts
* - malformed-input error paths (empty, non-string, no table, missing delimiter row)
* - matchTableSchema resolving every canonical variant + null for unknown headers
* - fast-check round-trip property test
* - registry <-> template/workflow parity guard (ADR-2143 §3 Generative-Fix-
* Divergence guard) — every TABLE_SCHEMAS header must appear verbatim in the
* source file that generates it
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns } = require('../gsd-core/bin/lib/markdown-table.cjs');
const ROOT = path.join(__dirname, '..');
// ─── parseMarkdownTable: happy path ───────────────────────────────────────────
describe('parseMarkdownTable: happy path', () => {
test('parses a 4-column flat RoadmapProgress table, cells addressed by name', () => {
const src = [
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, true);
assert.deepEqual(result.value.columns, ['Phase', 'Plans Complete', 'Status', 'Completed']);
assert.equal(result.value.rows.length, 2);
assert.equal(result.value.rows[0]['Phase'], '1. Alpha');
assert.equal(result.value.rows[0]['Plans Complete'], '2/2');
assert.equal(result.value.rows[0]['Status'], 'Complete');
assert.equal(result.value.rows[1]['Status'], 'In Progress');
});
test('parses a 5-column milestone-grouped RoadmapProgress table, cells addressed by name', () => {
const src = [
'| Phase | Milestone | Plans Complete | Status | Completed |',
'|---|---|---|---|---|',
'| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |',
'| 2. Beta | v1.1 | 0/3 | Planned | |',
].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, true);
assert.deepEqual(result.value.columns, ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed']);
assert.equal(result.value.rows[0]['Milestone'], 'v1.0');
assert.equal(result.value.rows[1]['Milestone'], 'v1.1');
assert.equal(result.value.rows[1]['Status'], 'Planned');
});
test('finds the FIRST table when the section has leading prose', () => {
const src = [
'Some intro prose before the table.',
'',
'| Requirement | Phase | Status |',
'| --- | --- | --- |',
'| R1 | 1 | Done |',
].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, true);
assert.deepEqual(result.value.columns, ['Requirement', 'Phase', 'Status']);
assert.equal(result.value.rows[0]['Requirement'], 'R1');
});
});
// ─── BOUNDARY coverage: ragged data rows ──────────────────────────────────────
describe('parseMarkdownTable: boundary coverage (ragged rows)', () => {
const header = '| Phase | Plans Complete | Status | Completed |';
const delimiter = '| --- | --- | --- | --- |';
test('limit-1: a 3-cell data row (one short of the 4-column header) is a typed error', () => {
const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete |'].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, false);
assert.match(result.reason, /row 1 has 3 cells, expected 4/);
});
test('limit: a 4-cell data row (exactly matching the 4-column header) parses ok', () => {
const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete | ✅ |'].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, true);
assert.equal(result.value.rows.length, 1);
});
test('limit+1: a 5-cell data row (one over the 4-column header) is a typed error', () => {
const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete | ✅ | extra |'].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, false);
assert.match(result.reason, /row 1 has 5 cells, expected 4/);
});
});
// ─── Malformed input ──────────────────────────────────────────────────────────
describe('parseMarkdownTable: malformed input', () => {
test('empty string returns a typed error', () => {
const result = parseMarkdownTable('');
assert.equal(result.ok, false);
assert.equal(result.reason, 'empty or non-string input');
});
test('whitespace-only string returns a typed error', () => {
const result = parseMarkdownTable(' \n \n ');
assert.equal(result.ok, false);
assert.equal(result.reason, 'empty or non-string input');
});
test('non-string input returns a typed error, does not throw', () => {
let result;
assert.doesNotThrow(() => {
result = parseMarkdownTable(42);
});
assert.equal(result.ok, false);
assert.equal(result.reason, 'empty or non-string input');
});
test('content with no pipe table returns a typed error', () => {
const result = parseMarkdownTable('# Heading\n\nJust some prose, no table here.\n');
assert.equal(result.ok, false);
assert.equal(result.reason, 'no table found');
});
test('missing delimiter row returns a typed error', () => {
const src = [
'| Phase | Plans Complete | Status | Completed |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, false);
assert.equal(result.reason, 'missing delimiter row');
});
test('delimiter row present but column count mismatch returns a typed error', () => {
const src = [
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
].join('\n');
const result = parseMarkdownTable(src);
assert.equal(result.ok, false);
assert.equal(result.reason, 'delimiter/header column count mismatch');
});
});
// ─── matchTableSchema ──────────────────────────────────────────────────────────
describe('matchTableSchema', () => {
test('resolves every canonical variant header to its {id,label}', () => {
for (const [id, variants] of Object.entries(TABLE_SCHEMAS)) {
for (const variant of variants) {
const match = matchTableSchema(variant.columns);
assert.deepEqual(
match,
{ id, label: variant.label },
`expected ${id}/${variant.label} to resolve for columns ${JSON.stringify(variant.columns)}`,
);
}
}
});
test('returns null for an unknown header', () => {
const match = matchTableSchema(['Foo', 'Bar', 'Baz']);
assert.equal(match, null);
});
test('returns null when column order differs from every variant', () => {
const match = matchTableSchema(['Status', 'Phase', 'Plans Complete', 'Completed']);
assert.equal(match, null);
});
test('returns null when column count differs from every variant', () => {
const match = matchTableSchema(['Phase', 'Plans Complete', 'Status']);
assert.equal(match, null);
});
});
// ─── Property test: round-trip render -> parse ────────────────────────────────
describe('parseMarkdownTable: property-based round-trip', () => {
// Safe cell text: no '|' or newline, non-empty, bounded length.
const safeCell = fc
.string({ minLength: 1, maxLength: 8 })
.filter((s) => !s.includes('|') && !s.includes('\n') && !s.includes('\r') && s.trim().length > 0)
.map((s) => s.trim());
const safeColumnName = fc
.string({ minLength: 1, maxLength: 6 })
.filter((s) => !s.includes('|') && !s.includes('\n') && !s.includes('\r') && s.trim().length > 0)
.map((s) => s.trim());
function renderTable(columns, rows) {
const lines = [];
lines.push(`| ${columns.join(' | ')} |`);
lines.push(`| ${columns.map(() => '---').join(' | ')} |`);
for (const row of rows) {
lines.push(`| ${row.join(' | ')} |`);
}
return lines.join('\n');
}
// A table's rows depend on its column count, so derive the rows arbitrary
// from the generated columns via .chain() (dependent arbitrary generation) —
// never fc.sample() inside a property, which breaks shrinking/reproducibility.
const tableArb = fc.uniqueArray(safeColumnName, { minLength: 1, maxLength: 4 }).chain((columns) =>
fc.tuple(
fc.constant(columns),
fc.array(fc.array(safeCell, { minLength: columns.length, maxLength: columns.length }), { maxLength: 4 }),
),
);
test('property: rendering a table then parsing it round-trips columns and row values', () => {
fc.assert(
fc.property(tableArb, ([columns, rows]) => {
const src = renderTable(columns, rows);
const result = parseMarkdownTable(src);
assert.equal(result.ok, true, `expected ok:true, got ${JSON.stringify(result)}`);
assert.deepEqual(result.value.columns, columns);
assert.equal(result.value.rows.length, rows.length);
rows.forEach((cells, i) => {
columns.forEach((col, j) => {
assert.equal(result.value.rows[i][col], cells[j]);
});
});
}),
);
});
});
// ─── appendQuickTaskRow (#2133) ────────────────────────────────────────────────
describe('appendQuickTaskRow (#2133)', () => {
const noStatusState = [
'# STATE',
'',
'### Quick Tasks Completed',
'',
'| # | Description | Date | Commit | Directory |',
'|---|-------------|------|--------|-----------|',
'| 1 | fix typo | 2026-01-01 | abc1234 | — |',
'',
'### Blockers/Concerns',
'None',
].join('\n');
const withStatusState = [
'# STATE',
'',
'### Quick Tasks Completed',
'',
'| # | Description | Date | Commit | Status | Directory |',
'|---|-------------|------|--------|--------|-----------|',
'| 1 | fix typo | 2026-01-01 | abc1234 | Pass | — |',
'',
'### Blockers/Concerns',
'None',
].join('\n');
test('5-col no-status table: appends a 5-cell row, content contains it, variant is no-status', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'add missing import',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
assert.equal(result.value.variant, 'no-status');
assert.equal(result.value.row, '| 2 | add missing import | 2026-07-13 | a574966 | — |');
assert.ok(result.value.content.includes(result.value.row));
});
test('6-col with-status table: appends a 6-cell row, variant is with-status', () => {
const result = appendQuickTaskRow(withStatusState, {
description: 'bump version',
date: '2026-07-13',
commit: 'b6fc5f6',
status: 'Needs Review',
});
assert.equal(result.ok, true);
assert.equal(result.value.variant, 'with-status');
assert.equal(result.value.row, '| 2 | bump version | 2026-07-13 | b6fc5f6 | Needs Review | — |');
assert.ok(result.value.content.includes(result.value.row));
});
test('unknown/garbled header (4-col table): fails loud with a reason instead of silently skipping', () => {
const garbled = [
'# STATE',
'',
'### Quick Tasks Completed',
'',
'| Foo | Bar | Baz | Qux |',
'|---|---|---|---|',
'| 1 | 2 | 3 | 4 |',
].join('\n');
const result = appendQuickTaskRow(garbled, { description: 'x', date: '2026-07-13', commit: 'abc' });
assert.equal(result.ok, false);
assert.match(result.reason, /unrecognized Quick Tasks schema/);
});
test('no "Quick Tasks Completed" section: fails loud with a reason', () => {
const noSection = '# STATE\n\n### Blockers/Concerns\nNone\n';
const result = appendQuickTaskRow(noSection, { description: 'x', date: '2026-07-13', commit: 'abc' });
assert.equal(result.ok, false);
assert.match(result.reason, /no Quick Tasks Completed section/);
});
test('boundary: next row number is 1 with zero data rows, 3 with two data rows', () => {
const zeroRows = [
'# STATE',
'',
'### Quick Tasks Completed',
'',
'| # | Description | Date | Commit | Directory |',
'|---|-------------|------|--------|-----------|',
].join('\n');
const zeroResult = appendQuickTaskRow(zeroRows, { description: 'x', date: '2026-07-13', commit: 'abc' });
assert.equal(zeroResult.ok, true);
assert.match(zeroResult.value.row, /^\| 1 \|/);
const twoRows = [
'# STATE',
'',
'### Quick Tasks Completed',
'',
'| # | Description | Date | Commit | Directory |',
'|---|-------------|------|--------|-----------|',
'| 1 | first | 2026-01-01 | aaa1111 | — |',
'| 2 | second | 2026-01-02 | bbb2222 | — |',
].join('\n');
const twoResult = appendQuickTaskRow(twoRows, { description: 'x', date: '2026-07-13', commit: 'abc' });
assert.equal(twoResult.ok, true);
assert.match(twoResult.value.row, /^\| 3 \|/);
});
test('appended row cell count equals the header column count (round-trips via parseMarkdownTable)', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'round trip check',
date: '2026-07-13',
commit: 'ccc3333',
});
assert.equal(result.ok, true);
const section = result.value.content.split('### Blockers/Concerns')[0];
const reparsed = parseMarkdownTable(section);
assert.equal(reparsed.ok, true);
assert.equal(reparsed.value.rows.length, 2);
for (const row of reparsed.value.rows) {
assert.equal(Object.keys(row).length, reparsed.value.columns.length);
}
});
// ─── Regression: cell-value escaping (#2242 review Fix 1) ──────────────────
// A raw `|` or newline in `description` used to be inserted verbatim,
// corrupting the table (extra column / a fake extra row) — the now-fail-loud
// parseMarkdownTable rejects the resulting ragged row.
test('description containing "|" round-trips: ok:true, no ragged row, cell value preserved', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'fix a | b bug',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
const section = result.value.content.split('### Blockers/Concerns')[0];
const reparsed = parseMarkdownTable(section);
assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`);
assert.equal(reparsed.value.rows.length, 2);
assert.equal(reparsed.value.rows[1]['Description'], 'fix a | b bug');
});
// ─── Regression: backslash escaping (CodeQL js/incomplete-sanitization) ────
// escapeCell used to escape `|` -> `\|` without first escaping a literal `\`,
// so a description with a raw backslash (e.g. a Windows path) could produce
// an escape sequence that splitTableRow misreads on unescape. escapeCell now
// escapes `\` -> `\\` before `|` -> `\|`, and splitTableRow reverses both.
test('description containing a literal backslash round-trips byte-for-byte', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'fix C:\\path bug',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
const section = result.value.content.split('### Blockers/Concerns')[0];
const reparsed = parseMarkdownTable(section);
assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`);
assert.equal(reparsed.value.rows.length, 2);
assert.equal(reparsed.value.rows[1]['Description'], 'fix C:\\path bug');
});
test('description containing backslash-pipe ("a\\|b") round-trips to exactly "a\\|b"', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'a\\|b',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
const section = result.value.content.split('### Blockers/Concerns')[0];
const reparsed = parseMarkdownTable(section);
assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`);
assert.equal(reparsed.value.rows.length, 2);
assert.equal(reparsed.value.rows[1]['Description'], 'a\\|b');
});
test('description containing a newline collapses to a single-line cell and round-trips', () => {
const result = appendQuickTaskRow(noStatusState, {
description: 'line one\nline two',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
// Collapsed to a single line: the row itself must not contain a newline.
assert.ok(!result.value.row.includes('\n'));
const section = result.value.content.split('### Blockers/Concerns')[0];
const reparsed = parseMarkdownTable(section);
assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`);
assert.equal(reparsed.value.rows.length, 2);
assert.equal(reparsed.value.rows[1]['Description'], 'line one line two');
});
// ─── Regression: CRLF preservation (#2242 review Fix 3) ─────────────────────
// section.body used to be split on /\r?\n/ and rejoined with '\n', downgrading
// a CRLF section to mixed EOL.
test('CRLF-input STATE.md keeps \\r\\n in the touched section (no mixed EOL)', () => {
const crlfState = noStatusState.replace(/\n/g, '\r\n');
const result = appendQuickTaskRow(crlfState, {
description: 'crlf check',
date: '2026-07-13',
commit: 'a574966',
});
assert.equal(result.ok, true);
const section = result.value.content.split('### Blockers/Concerns')[0];
// No mixed EOL: every line break in the touched section is \r\n, and there
// must be no bare \n (i.e. no \n NOT preceded by \r).
assert.ok(!/(?<!\r)\n/.test(section), 'expected no bare \\n (mixed EOL) in the touched section');
assert.ok(section.includes('\r\n'), 'expected \\r\\n to be preserved in the touched section');
});
});
// ─── findTableBySchema (#2242 review Fix 4) ────────────────────────────────────
describe('findTableBySchema', () => {
test('finds a RoadmapProgress table that appears after other content, not under a "## Progress" heading', () => {
const doc = [
'# Roadmap',
'',
'## Overview',
'',
'Some prose describing the roadmap. No table here.',
'',
'## Milestone v1.0: Test',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
].join('\n');
const table = findTableBySchema(doc, 'RoadmapProgress');
assert.notEqual(table, null);
assert.deepEqual(table.columns, ['Phase', 'Plans Complete', 'Status', 'Completed']);
assert.equal(table.rows.length, 2);
assert.equal(table.rows[0]['Phase'], '1. Alpha');
});
test('finds a RoadmapProgress table that is not the first table in the document', () => {
const doc = [
'# Roadmap',
'',
'## Legend',
'',
'| Symbol | Meaning |',
'| --- | --- |',
'| ✅ | Done |',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
].join('\n');
const table = findTableBySchema(doc, 'RoadmapProgress');
assert.notEqual(table, null);
assert.equal(table.rows.length, 1);
assert.equal(table.rows[0]['Phase'], '1. Alpha');
});
test('returns null when no table matches the given schema', () => {
const doc = [
'# Roadmap',
'',
'| Symbol | Meaning |',
'| --- | --- |',
'| ✅ | Done |',
].join('\n');
assert.equal(findTableBySchema(doc, 'RoadmapProgress'), null);
});
test('returns null for non-string input', () => {
assert.equal(findTableBySchema(undefined, 'RoadmapProgress'), null);
});
});
// ─── findTableWithColumns (#2242: column-order/count-invariant reader seam) ──
describe('findTableWithColumns', () => {
test('finds a table whose header has the required columns in shuffled order, plus extra/injected columns', () => {
const doc = [
'## Progress',
'',
'| Status | Foo | Phase | Plans Complete | Completed |',
'| --- | --- | --- | --- | --- |',
'| Complete | x | 1. Alpha | 2/2 | ✅ |',
'| In Progress | x | 2. Beta | 1/2 | |',
].join('\n');
const table = findTableWithColumns(doc, ['Phase', 'Plans Complete', 'Status', 'Completed']);
assert.notEqual(table, null);
assert.deepEqual(table.columns, ['Status', 'Foo', 'Phase', 'Plans Complete', 'Completed']);
assert.equal(table.rows.length, 2);
assert.equal(table.rows[0]['Phase'], '1. Alpha');
assert.equal(table.rows[0]['Status'], 'Complete');
assert.equal(table.rows[1]['Plans Complete'], '1/2');
});
test('returns null when a required column is absent from every table header', () => {
const doc = [
'## Progress',
'',
'| Phase | Owner | Completed |',
'| --- | --- | --- |',
'| 1. Alpha | jo | ✅ |',
].join('\n');
assert.equal(findTableWithColumns(doc, ['Phase', 'Plans Complete', 'Status', 'Completed']), null);
});
test('returns null for non-string input', () => {
assert.equal(findTableWithColumns(undefined, ['Phase']), null);
});
});
// ─── PARITY / DRIFT guard: registry <-> template/workflow source files ───────
describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their source templates', () => {
/**
* Build the `| a | b | c |` header line for a variant and assert the given
* source file contains it verbatim (whitespace around pipes normalized so
* template formatting quirks don't cause false failures).
*/
function assertHeaderInFile(relPath, variant) {
const fullPath = path.join(ROOT, relPath);
const content = fs.readFileSync(fullPath, 'utf8'); // allow-test-rule: runtime-contract-is-the-product — template/registry parity (#2242)
const expectedHeader = `| ${variant.columns.join(' | ')} |`;
const normalize = (s) => s.replace(/[ \t]*\|[ \t]*/g, '|').trim();
const normalizedExpected = normalize(expectedHeader);
const found = content
.split(/\r?\n/)
.some((line) => normalize(line) === normalizedExpected);
assert.ok(
found,
`expected header ${JSON.stringify(expectedHeader)} to appear verbatim in ${relPath}`,
);
}
test('RoadmapProgress variants appear in gsd-core/templates/roadmap.md', () => {
for (const variant of TABLE_SCHEMAS.RoadmapProgress) {
assertHeaderInFile('gsd-core/templates/roadmap.md', variant);
}
});
test('RequirementsTraceability variant appears in gsd-core/templates/requirements.md', () => {
for (const variant of TABLE_SCHEMAS.RequirementsTraceability) {
assertHeaderInFile('gsd-core/templates/requirements.md', variant);
}
});
test('QuickTasks variants appear in gsd-core/workflows/quick.md', () => {
for (const variant of TABLE_SCHEMAS.QuickTasks) {
assertHeaderInFile('gsd-core/workflows/quick.md', variant);
}
});
test('Security variants appear in gsd-core/templates/SECURITY.md', () => {
for (const variant of TABLE_SCHEMAS.Security) {
assertHeaderInFile('gsd-core/templates/SECURITY.md', variant);
}
});
});