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>
This commit is contained in:
Tom Boucher
2026-07-13 20:36:14 -04:00
committed by GitHub
parent db725e49e0
commit d49ac81306
38 changed files with 1397 additions and 376 deletions

View File

@@ -1,178 +0,0 @@
/**
* #2133 — fast.md log_to_state schema gate is unreachable.
*
* PR #85 added a column-count guard to fast.md's log_to_state step that used
* `awk -F'|' '{print NF-1}'`. A markdown table header has both a leading and a
* trailing pipe, so NF counts (real columns + 2) and NF-1 is always one too
* high. The `-eq 5` test could therefore never hold for the 5-column header
* quick.md writes, so /gsd-fast has never appended a Quick Task row since #85.
*
* This test does what the removed prose-regex test (bug-3805-*) did not: it
* EXTRACTS the actual bash block deployed in fast.md and EXECUTES it against
* real STATE.md fixtures, asserting on the filesystem result (row appended,
* cell count aligned with header). It fails on the NF-1 bug and passes once
* the count uses NF-2 and both 5/6-column schemas are accepted.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempProject, cleanup } = require('./helpers.cjs');
const FAST_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'fast.md');
const HEADER_5COL = '| # | Description | Date | Commit | Directory |';
const SEP_5COL = '|---|-------------|------|--------|-----------|';
const HEADER_6COL = '| # | Description | Date | Commit | Status | Directory |';
const SEP_6COL = '|---|-------------|------|--------|--------|-----------|';
/**
* Extract the ```bash block embedded in fast.md's <step name="log_to_state">.
* This is the exact program the workflow runs — executing it is a behavioral
* test of the deployed product, not a source-grep over its prose.
*/
function extractLogToStateBash() {
const content = fs.readFileSync(FAST_MD, 'utf8');
const stepTag = '<step name="log_to_state">';
const stepStart = content.indexOf(stepTag);
assert.notEqual(stepStart, -1, 'fast.md must contain a log_to_state step');
const stepEnd = content.indexOf('</step>', stepStart);
assert.notEqual(stepEnd, -1, 'log_to_state step must close');
const step = content.slice(stepStart, stepEnd);
const fenceStart = step.indexOf('```bash');
assert.notEqual(fenceStart, -1, 'log_to_state step must contain a bash block');
const codeStart = step.indexOf('\n', fenceStart) + 1;
const fenceEnd = step.indexOf('\n```', codeStart);
assert.notEqual(fenceEnd, -1, 'log_to_state bash block must close');
return step.slice(codeStart, fenceEnd);
}
function makeStateMd(headerLine, separatorLine, existingRows) {
return [
'# Project State',
'',
'### Blockers/Concerns',
'',
'None.',
'',
'### Quick Tasks Completed',
'',
headerLine,
separatorLine,
...existingRows,
'',
].join('\n');
}
/** Count `|` chars on a line — the invariant cell-count signal. */
function pipeCount(line) {
return (line.match(/\|/g) || []).length;
}
/** Data rows = lines starting with `|` that are not the separator or header. */
function dataRows(content) {
return content.split(/\r?\n/).filter((l) => {
if (!l.startsWith('|')) return false;
if (/^[|][-: |]*[|]$/.test(l)) return false; // separator
if (/Description/.test(l)) return false; // header
return true;
});
}
describe('#2133 fast.md log_to_state schema gate', () => {
const bashBlock = extractLogToStateBash();
const TASK_DESC = 'sample inline fix';
/**
* Run the extracted log_to_state bash against a temp project's STATE.md and
* return the post-run file content + captured stdout.
*/
function runAgainst(headerLine, separatorLine, existingRows) {
const tmpDir = createTempProject('fix-2133-');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, makeStateMd(headerLine, separatorLine, existingRows));
// Cleanup is bound by each caller via t.after(tmpDir).
const stdout = execFileSync('bash', ['-c', bashBlock], {
cwd: tmpDir,
env: { ...process.env, TASK: TASK_DESC },
encoding: 'utf8',
});
const after = fs.readFileSync(statePath, 'utf8');
return { tmpDir, stdout, after };
}
test('appends a 5-cell row to the 5-column (non-validate) schema (#27 stays fixed)', (t) => {
const before = makeStateMd(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']);
const { tmpDir, after } = runAgainst(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']);
t.after(() => cleanup(tmpDir));
const rowsBefore = dataRows(before).length;
const rowsAfter = dataRows(after).length;
assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended');
const appended = dataRows(after).slice(-1)[0];
assert.equal(pipeCount(appended), pipeCount(HEADER_5COL),
'appended row pipe-count must match the 5-column header (no malformed row)');
assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description');
});
test('appends a 6-cell row to the 6-column (validate, with Status) schema', (t) => {
const before = makeStateMd(HEADER_6COL, SEP_6COL, []);
const { tmpDir, after } = runAgainst(HEADER_6COL, SEP_6COL, []);
t.after(() => cleanup(tmpDir));
const rowsBefore = dataRows(before).length;
const rowsAfter = dataRows(after).length;
assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended to the 6-column table');
const appended = dataRows(after).slice(-1)[0];
assert.equal(pipeCount(appended), pipeCount(HEADER_6COL),
'appended row pipe-count must match the 6-column header (cell count aligned with header)');
assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description');
});
test('column count awk uses NF-2 (real columns — the off-by-one root cause)', () => {
// The deployed bash must compute the real column count. A 5-column header
// split on '|' yields NF=7; the correct real-column formula is NF-2=5.
// (NF-1 was the off-by-one bug: it returned 6, making `-eq 5` unsatisfiable.)
// Match the executable COL_COUNT assignment specifically — the explanatory
// comment may still reference "NF-1" to document the history.
assert.match(
bashBlock,
/COL_COUNT=\$\(.+awk -F'\|' '\{print NF-2\}'\)/,
'COL_COUNT must be derived via awk NF-2 (NF-1 was the off-by-one bug)'
);
});
test('unrecognized schema still skips with a warning (safety guard intact)', (t) => {
// A 3-column table quick.md never writes must NOT receive a row.
const weirdHeader = '| Alpha | Beta | Gamma |';
const weirdSep = '|-------|------|-------|';
const before = makeStateMd(weirdHeader, weirdSep, []);
const { tmpDir, stdout, after } = runAgainst(weirdHeader, weirdSep, []);
t.after(() => cleanup(tmpDir));
assert.equal(dataRows(after).length, dataRows(before).length,
'no row may be appended for an unrecognized schema');
assert.ok(/unrecognized schema/i.test(stdout),
'the unrecognized-schema warning must be emitted');
});
test('no Quick Tasks table → silent no-op', (t) => {
const tmpDir = createTempProject('fix-2133-noop-');
t.after(() => cleanup(tmpDir));
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = '# Project State\n\n### Blockers/Concerns\n\nNone.\n';
fs.writeFileSync(statePath, before);
const stdout = execFileSync('bash', ['-c', bashBlock], {
cwd: tmpDir,
env: { ...process.env, TASK: TASK_DESC },
encoding: 'utf8',
});
const after = fs.readFileSync(statePath, 'utf8');
assert.equal(after, before, 'STATE.md must be untouched when no Quick Tasks table exists');
assert.equal(stdout, '', 'no output when there is no table to update');
});
});

View File

@@ -38,7 +38,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
"gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb",
"gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "907af77eafc3d97b",
"gsd-core/workflows/explore.md": "934c00f9f216dbdb",
"gsd-core/workflows/extract-learnings.md": "167ea7f0e23bf496",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "568e6c3ec00b6e00",
"gsd-core/workflows/forensics.md": "b64f0309b8c3fde1",
"gsd-core/workflows/graduation.md": "a9d8f15ba81a993f",
"gsd-core/workflows/health.md": "1ac4d567ee95acfb",
@@ -280,7 +280,7 @@
"gsd-core/workflows/pr-branch.md": "dc5598ae8accdecd",
"gsd-core/workflows/profile-user.md": "355af92ac285567f",
"gsd-core/workflows/progress.md": "79a11ce798082054",
"gsd-core/workflows/quick.md": "7108075f69e88e7c",
"gsd-core/workflows/quick.md": "424c0eb61769e747",
"gsd-core/workflows/reapply-patches.md": "4dcd6117d0a507ca",
"gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e",
"gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "2c412310dce31a0b",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b",
"gsd-core/workflows/profile-user.md": "14263db831230142",
"gsd-core/workflows/progress.md": "893aa3c36983f74b",
"gsd-core/workflows/quick.md": "069fe37d083a94a3",
"gsd-core/workflows/quick.md": "25f0cc40bc22ed3e",
"gsd-core/workflows/reapply-patches.md": "39050f72601aec89",
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",

View File

@@ -108,7 +108,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -312,7 +312,7 @@
"gsd-core/workflows/execute-plan.md": "f17623fd47e795dd",
"gsd-core/workflows/explore.md": "95e463d4bdd6dadd",
"gsd-core/workflows/extract-learnings.md": "fd75072c339b58bd",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "7f7687b920d79b29",
"gsd-core/workflows/forensics.md": "857d7b064f4cca21",
"gsd-core/workflows/graduation.md": "ecf8da93e094fd2e",
"gsd-core/workflows/health.md": "551e63aa6f3df711",
@@ -350,7 +350,7 @@
"gsd-core/workflows/pr-branch.md": "513f6cff722eff2d",
"gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b",
"gsd-core/workflows/progress.md": "2be3a57916eccf87",
"gsd-core/workflows/quick.md": "20f9dbfcd20b4b4b",
"gsd-core/workflows/quick.md": "f1b474b46327034f",
"gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb",
"gsd-core/workflows/remove-phase.md": "8effc8742d58a11a",
"gsd-core/workflows/remove-workspace.md": "10882656198d9075",

View File

@@ -37,7 +37,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -241,7 +241,7 @@
"gsd-core/workflows/execute-plan.md": "cce1a33fe9a0a32d",
"gsd-core/workflows/explore.md": "b9eea1bac358c9ce",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "41a6568b873aef99",
"gsd-core/workflows/forensics.md": "c01da0178fb97b21",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "f934b1f1e9f3ae72",
@@ -279,7 +279,7 @@
"gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd",
"gsd-core/workflows/profile-user.md": "ff3820a27731ceb8",
"gsd-core/workflows/progress.md": "bd1ecf9207331bda",
"gsd-core/workflows/quick.md": "3363bdfefd403686",
"gsd-core/workflows/quick.md": "b4237633eaf5a246",
"gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041",
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
"gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed",

View File

@@ -41,7 +41,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf",
"gsd-core/bin/gsd-tools.cjs": "a84914f7cab74332",
"gsd-core/bin/gsd-tools.cjs": "49dfaa890fdd5627",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -245,7 +245,7 @@
"gsd-core/workflows/execute-plan.md": "0c5551f99ee0a017",
"gsd-core/workflows/explore.md": "e83af8ceae314cf9",
"gsd-core/workflows/extract-learnings.md": "6f39375b7dc775f9",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "e4f74a454b6ca5e8",
"gsd-core/workflows/forensics.md": "9fc65a8eed5d8bfc",
"gsd-core/workflows/graduation.md": "16fedecda36769eb",
"gsd-core/workflows/health.md": "788fad84fa42eb9f",
@@ -283,7 +283,7 @@
"gsd-core/workflows/pr-branch.md": "9923878a4f6a2d91",
"gsd-core/workflows/profile-user.md": "26f74db0a7fcd268",
"gsd-core/workflows/progress.md": "9f326d63afb4b76b",
"gsd-core/workflows/quick.md": "2afc046e94daad0a",
"gsd-core/workflows/quick.md": "797b1c8d62c3d33d",
"gsd-core/workflows/reapply-patches.md": "eb4272145a117904",
"gsd-core/workflows/remove-phase.md": "e336350f8113a328",
"gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "933d10547116794a",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b",
"gsd-core/workflows/profile-user.md": "4fa910d15dea5695",
"gsd-core/workflows/progress.md": "893aa3c36983f74b",
"gsd-core/workflows/quick.md": "cc530299ba461539",
"gsd-core/workflows/quick.md": "0bd83bd88c5e63d1",
"gsd-core/workflows/reapply-patches.md": "39050f72601aec89",
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",

View File

@@ -144,7 +144,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -348,7 +348,7 @@
"gsd-core/workflows/execute-plan.md": "ac1f1d9ada00a91e",
"gsd-core/workflows/explore.md": "2ef10d17c8864a04",
"gsd-core/workflows/extract-learnings.md": "f716aa03fcb5f8da",
"gsd-core/workflows/fast.md": "bbf1f8e219ec9031",
"gsd-core/workflows/fast.md": "e4ed60f96a7b3ac8",
"gsd-core/workflows/forensics.md": "2e8a01b5b44e65f3",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "5b835fc606fd3e9b",
@@ -386,7 +386,7 @@
"gsd-core/workflows/pr-branch.md": "d13e1cc81de40896",
"gsd-core/workflows/profile-user.md": "05828c8cc61ef384",
"gsd-core/workflows/progress.md": "7bedc431bb55edb8",
"gsd-core/workflows/quick.md": "62b9e138dc70b479",
"gsd-core/workflows/quick.md": "bf70b7beb314aa50",
"gsd-core/workflows/reapply-patches.md": "26297b84736e66a4",
"gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4",
"gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62",

View File

@@ -39,7 +39,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
"gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb",
"gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -243,7 +243,7 @@
"gsd-core/workflows/execute-plan.md": "c5e9dae726db15cc",
"gsd-core/workflows/explore.md": "5fd91a8510e1114b",
"gsd-core/workflows/extract-learnings.md": "f34d0b1927545b18",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "66821090b6b8ed3b",
"gsd-core/workflows/forensics.md": "459644dce26ee2ef",
"gsd-core/workflows/graduation.md": "f013efc29096faf0",
"gsd-core/workflows/health.md": "f343fd32a0e398e4",
@@ -281,7 +281,7 @@
"gsd-core/workflows/pr-branch.md": "2833905f119b5722",
"gsd-core/workflows/profile-user.md": "5cc032206c99ef71",
"gsd-core/workflows/progress.md": "6b9a84a43dc55af5",
"gsd-core/workflows/quick.md": "eb68ea8748546331",
"gsd-core/workflows/quick.md": "4a53899b69b8c801",
"gsd-core/workflows/reapply-patches.md": "8fd59e24b486f180",
"gsd-core/workflows/remove-phase.md": "e262654e319d1bc4",
"gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828",
"gsd-core/bin/gsd-tools.cjs": "db16ded31dd6eecf",
"gsd-core/bin/gsd-tools.cjs": "50658d517405cd63",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "83dc1bf7f73735c0",
"gsd-core/workflows/explore.md": "b9eea1bac358c9ce",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/fast.md": "77b49793e26b3323",
"gsd-core/workflows/forensics.md": "a65f817d4a515291",
"gsd-core/workflows/graduation.md": "53a4a6fa3b4e6613",
"gsd-core/workflows/health.md": "e4d770484b5e7496",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "c67d90c65da47168",
"gsd-core/workflows/profile-user.md": "8c943983241260b5",
"gsd-core/workflows/progress.md": "65aabee5e8a6dd82",
"gsd-core/workflows/quick.md": "32a71f62041c8510",
"gsd-core/workflows/quick.md": "3b37350964cfe391",
"gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041",
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
"gsd-core/workflows/remove-workspace.md": "433affcd1a200826",

View File

@@ -38,7 +38,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f",
"gsd-core/bin/gsd-tools.cjs": "b3f927ee4b4c4711",
"gsd-core/bin/gsd-tools.cjs": "12ee14a48b678d2b",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "4dbe9b6f0c976245",
"gsd-core/workflows/explore.md": "48770d68e8b9c132",
"gsd-core/workflows/extract-learnings.md": "e9e167c718949c0b",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "c801145115755524",
"gsd-core/workflows/forensics.md": "91961b811917c5c4",
"gsd-core/workflows/graduation.md": "d1fd52bbe41dcf34",
"gsd-core/workflows/health.md": "c622a5ad0d347d30",
@@ -280,7 +280,7 @@
"gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229",
"gsd-core/workflows/profile-user.md": "de5030437226cf2c",
"gsd-core/workflows/progress.md": "f18db000584d9cb1",
"gsd-core/workflows/quick.md": "e7a395b9e7786b56",
"gsd-core/workflows/quick.md": "731c20b5605bd319",
"gsd-core/workflows/reapply-patches.md": "158083a310859594",
"gsd-core/workflows/remove-phase.md": "fce799aae3ab2715",
"gsd-core/workflows/remove-workspace.md": "8facde381657dd71",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "8dc89b35582407f7",
"gsd-core/workflows/explore.md": "14242d36d4822df6",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "41a6568b873aef99",
"gsd-core/workflows/forensics.md": "c01da0178fb97b21",
"gsd-core/workflows/graduation.md": "5cc8638dabcbfd40",
"gsd-core/workflows/health.md": "064b3668d5d9569a",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd",
"gsd-core/workflows/profile-user.md": "203ebe3f8f3876a8",
"gsd-core/workflows/progress.md": "9381c59676ccb937",
"gsd-core/workflows/quick.md": "e71c92cf0463a461",
"gsd-core/workflows/quick.md": "4ddd6d3214f57f57",
"gsd-core/workflows/reapply-patches.md": "becf9728cdb124c4",
"gsd-core/workflows/remove-phase.md": "ada8a0546c686483",
"gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7",

View File

@@ -102,7 +102,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -306,7 +306,7 @@
"gsd-core/workflows/execute-plan.md": "c8502b7475d797a7",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",
@@ -344,7 +344,7 @@
"gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b",
"gsd-core/workflows/profile-user.md": "5abfae83739fa978",
"gsd-core/workflows/progress.md": "893aa3c36983f74b",
"gsd-core/workflows/quick.md": "09d88dd1c0b3e03a",
"gsd-core/workflows/quick.md": "8dcda37c46954ebe",
"gsd-core/workflows/reapply-patches.md": "39050f72601aec89",
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "e8de8ea661c1fe81",
"gsd-core/workflows/explore.md": "7f5f9231cfd3089b",
"gsd-core/workflows/extract-learnings.md": "92b3c0979604b7d0",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "12187b242e6af970",
"gsd-core/workflows/forensics.md": "9354cb830152fd28",
"gsd-core/workflows/graduation.md": "13080b5c24eec27d",
"gsd-core/workflows/health.md": "a91e0a8e5d20b2e9",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "929b7cb0c99c7b9e",
"gsd-core/workflows/profile-user.md": "248d59a31948e0ed",
"gsd-core/workflows/progress.md": "8fc3404087f50b95",
"gsd-core/workflows/quick.md": "9bdccb7c6a5530c9",
"gsd-core/workflows/quick.md": "774d646560a74c93",
"gsd-core/workflows/reapply-patches.md": "a0e9b53f90abceb2",
"gsd-core/workflows/remove-phase.md": "dea4661e8f89596f",
"gsd-core/workflows/remove-workspace.md": "446847e71aa52504",

View File

@@ -5,7 +5,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -209,7 +209,7 @@
"gsd-core/workflows/execute-plan.md": "ff172c3540b52e9d",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",
@@ -247,7 +247,7 @@
"gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b",
"gsd-core/workflows/profile-user.md": "1bac7f69142801ef",
"gsd-core/workflows/progress.md": "893aa3c36983f74b",
"gsd-core/workflows/quick.md": "4471707540842a41",
"gsd-core/workflows/quick.md": "31787c1e8a867e3f",
"gsd-core/workflows/reapply-patches.md": "39050f72601aec89",
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",

View File

@@ -38,7 +38,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
"gsd-core/bin/gsd-tools.cjs": "6454021dec4d9563",
"gsd-core/bin/gsd-tools.cjs": "205830afac36f33a",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "503b0ced0731dc38",
"gsd-core/workflows/explore.md": "e1a83a8982532e5b",
"gsd-core/workflows/extract-learnings.md": "dd4fdb88605de49a",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/fast.md": "93ed453edddd8c0c",
"gsd-core/workflows/forensics.md": "82800a3138ac1da9",
"gsd-core/workflows/graduation.md": "40401655435beee3",
"gsd-core/workflows/health.md": "043de14edb6a9723",
@@ -280,7 +280,7 @@
"gsd-core/workflows/pr-branch.md": "cef0f65b16d500b4",
"gsd-core/workflows/profile-user.md": "263c0693563d98da",
"gsd-core/workflows/progress.md": "3b1b2142a74af85c",
"gsd-core/workflows/quick.md": "bb9a4a145a7edc36",
"gsd-core/workflows/quick.md": "d0fffe516abaf47a",
"gsd-core/workflows/reapply-patches.md": "de0ee8acfe7245b2",
"gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0",
"gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e",

View File

@@ -38,7 +38,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527",
"gsd-core/bin/gsd-tools.cjs": "c28c9076058c5e73",
"gsd-core/bin/gsd-tools.cjs": "c8283c0c8888e357",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "4910f75bab2040ab",
"gsd-core/workflows/explore.md": "8a5437aa0c239c38",
"gsd-core/workflows/extract-learnings.md": "3fcc858b20d0d0e6",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/fast.md": "0f05b1e008ac2fc2",
"gsd-core/workflows/forensics.md": "665546666547875d",
"gsd-core/workflows/graduation.md": "1ca877cda258a5de",
"gsd-core/workflows/health.md": "1f9fd2deea45896f",
@@ -280,7 +280,7 @@
"gsd-core/workflows/pr-branch.md": "79fd55b88ea2db9c",
"gsd-core/workflows/profile-user.md": "672821e6b1266645",
"gsd-core/workflows/progress.md": "94768f835b0b8908",
"gsd-core/workflows/quick.md": "a568015cccb53615",
"gsd-core/workflows/quick.md": "0529abcf9913ec04",
"gsd-core/workflows/reapply-patches.md": "21b38c374f19fd78",
"gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86",
"gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663",

View File

@@ -38,7 +38,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2",
"gsd-core/bin/gsd-tools.cjs": "568e7c6bdd415c1a",
"gsd-core/bin/gsd-tools.cjs": "f21bb9ba5e55f642",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "c8567fb4438b4404",
"gsd-core/workflows/explore.md": "04e461ff8159a24e",
"gsd-core/workflows/extract-learnings.md": "af793bdf4ffd1c8a",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/fast.md": "b03b9f595892479a",
"gsd-core/workflows/forensics.md": "3d1ce16b5f605592",
"gsd-core/workflows/graduation.md": "a766039ff6ca653f",
"gsd-core/workflows/health.md": "7b19d6e2c0357c3e",
@@ -280,7 +280,7 @@
"gsd-core/workflows/pr-branch.md": "acd59f915d018ad4",
"gsd-core/workflows/profile-user.md": "c4313672b81b5bcd",
"gsd-core/workflows/progress.md": "18813a345bd2343a",
"gsd-core/workflows/quick.md": "b7813e1810c683e1",
"gsd-core/workflows/quick.md": "71fbcf38e2305de0",
"gsd-core/workflows/reapply-patches.md": "d449a23d3acf6379",
"gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b",
"gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a",

View File

@@ -109,7 +109,7 @@
"gsd-core/VERSION": "ef0deccd81a6723c",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
"gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038",
"gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4",
@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "ed874410972d32b7",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",
@@ -351,7 +351,7 @@
"gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b",
"gsd-core/workflows/profile-user.md": "e23bea0a69c0bb4b",
"gsd-core/workflows/progress.md": "893aa3c36983f74b",
"gsd-core/workflows/quick.md": "12917fdc9a624a0c",
"gsd-core/workflows/quick.md": "7ad8d250c536c4dd",
"gsd-core/workflows/reapply-patches.md": "39050f72601aec89",
"gsd-core/workflows/remove-phase.md": "df9a45f0b1880999",
"gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c",

View File

@@ -0,0 +1,613 @@
'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);
}
});
});

View File

@@ -0,0 +1,184 @@
'use strict';
/**
* Behavioral tests for phase-lifecycle.cjs
*
* Module: gsd-core/bin/lib/phase-lifecycle.cjs
* Exports: deriveProgressFromRoadmap, clampPercent
*
* ADR-2143 (epic #2143) migrated deriveProgressFromRoadmap from position-based
* regexes to the markdown-table schema registry (collectSection + parseMarkdownTable
* + matchTableSchema against TABLE_SCHEMAS.RoadmapProgress). This suite pins:
* - the pre-existing 4-column flat Progress table behaviour (unchanged)
* - #2137: the 5-column milestone-grouped Progress table (Phase | Milestone |
* Plans Complete | Status | Completed) — the OLD position-anchored regex
* assumed "Status" was always the 3rd cell, so it silently returned all-null
* for this variant; the schema-registry rewrite reads cells by column NAME
* and fixes this.
* - the pre-existing 999.x backlog exclusion for totalPhases
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { deriveProgressFromRoadmap, clampPercent } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
describe('deriveProgressFromRoadmap', () => {
test('parses the 4-column flat Progress table (behaviour preserved)', () => {
const roadmap = [
'# Roadmap',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
'| 3. Gamma | 0/1 | Planned | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.completedPhases, 1, `expected 1 completed phase, got ${result.completedPhases}`);
assert.equal(result.totalPhases, 3, `expected 3 total phases, got ${result.totalPhases}`);
assert.equal(result.totalPlans, 5, `expected totalPlans 5 (2+2+1), got ${result.totalPlans}`);
});
test('#2137: deriveProgressFromRoadmap parses the 5-column milestone-grouped Progress table', () => {
// Before ADR-2143: the old position-anchored regex assumed the 3rd cell was
// Status; here Status is the 4th cell (Milestone inserted at position 2), so
// the old code silently returned { completedPhases: null, totalPhases: null }.
const roadmap = [
'# Roadmap',
'',
'## Progress',
'',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |',
'| 2. Beta | v1.0 | 1/2 | In Progress | |',
'| 3. Gamma | v1.1 | 0/1 | Planned | |',
'| 4. Delta | v1.1 | 3/3 | Complete | ✅ |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.completedPhases, 2, `expected 2 completed phases (non-null), got ${result.completedPhases}`);
assert.equal(result.totalPhases, 4, `expected 4 total phases (non-null), got ${result.totalPhases}`);
assert.equal(result.totalPlans, 8, `expected totalPlans 8 (2+2+1+3), got ${result.totalPlans}`);
});
test('excludes 999.x backlog rows from totalPhases (4-column table)', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
'| 3. Gamma | 0/1 | Planned | |',
'| 999.1 Backlog: Future Idea | 0/0 | Backlog | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(
result.totalPhases,
3,
`total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`,
);
assert.equal(result.completedPhases, 1, `completed_phases must be 1. Got ${result.completedPhases}`);
});
test('excludes 999.x backlog rows from totalPhases (5-column milestone-grouped table)', () => {
const roadmap = [
'## Progress',
'',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |',
'| 2. Beta | v1.0 | 1/2 | In Progress | |',
'| 999.1 Backlog | v1.0 | 0/0 | Backlog | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(
result.totalPhases,
2,
`total_phases must be 2 (not 3) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`,
);
assert.equal(result.completedPhases, 1, `completed_phases must be 1. Got ${result.completedPhases}`);
});
test('non-table content returns all-null (no throw)', () => {
const result = deriveProgressFromRoadmap('# Roadmap\n\nNo table here.\n');
assert.equal(result.completedPhases, null);
assert.equal(result.totalPhases, null);
assert.equal(result.totalPlans, null);
});
// ─── Regression (#2242 review Fix 4): heading-dependency removed ───────────
// The Progress table used to be located via collectSection(h => /^progress$/i)
// first, THEN parsed — so a schema-matching table under a non-"Progress"
// heading (or one that isn't the first table in the document) returned
// all-null. deriveProgressFromRoadmap now delegates to findTableBySchema,
// which scans the whole document for the schema, independent of heading name.
test('Progress table under a NON-"Progress" heading, not the first table in the doc, still resolves', () => {
const roadmap = [
'# Roadmap',
'',
'## Legend',
'',
'| Symbol | Meaning |',
'| --- | --- |',
'| ✅ | Done |',
'',
'## Milestone v1.0: Alpha Release',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
'| 3. Gamma | 0/1 | Planned | |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.completedPhases, 1, `expected 1 completed phase (non-null), got ${result.completedPhases}`);
assert.equal(result.totalPhases, 3, `expected 3 total phases (non-null), got ${result.totalPhases}`);
assert.equal(result.totalPlans, 5, `expected totalPlans 5 (2+2+1), got ${result.totalPlans}`);
});
test('Progress table with NO heading at all, not the first table in the doc, still resolves', () => {
const roadmap = [
'# Roadmap',
'',
'Some intro prose describing conventions used below.',
'',
'| Symbol | Meaning |',
'| --- | --- |',
'| ✅ | Done |',
'',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.completedPhases, 1, `expected 1 completed phase (non-null), got ${result.completedPhases}`);
assert.equal(result.totalPhases, 1, `expected 1 total phase (non-null), got ${result.totalPhases}`);
assert.equal(result.totalPlans, 2, `expected totalPlans 2, got ${result.totalPlans}`);
});
});
describe('clampPercent', () => {
test('computes a normal percentage', () => {
assert.equal(clampPercent(1, 2), 50);
});
test('clamps to 100 when completed exceeds total', () => {
assert.equal(clampPercent(5, 2), 100);
});
test('returns 0 when total is 0 or negative', () => {
assert.equal(clampPercent(0, 0), 0);
assert.equal(clampPercent(3, -1), 0);
});
});

View File

@@ -441,25 +441,27 @@ describe('bug #3805: fast.md log_to_state must be schema-aware', () => {
});
test('fast.md log_to_state step references the 5-column quick.md schema', () => {
// The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory |
// Test that all 5 column names appear in the log_to_state step's context.
// Extract the log_to_state step content to scope the check.
// Schema-awareness no longer lives inline in fast.md as hardcoded column
// names — it was moved to the schema-backed `quick-tasks-append` helper
// (`appendQuickTaskRow` in markdown-table.cjs; #2133, ADR-2143 §3/§7),
// which introspects the existing table's schema (5-col or 6-col) itself.
// Assert the step delegates to that helper instead of requiring the old
// literal column names.
const logToStateMatch = fastMdContent.match(/<step name="log_to_state">([\s\S]*?)<\/step>/);
assert.ok(logToStateMatch, 'fast.md must contain a <step name="log_to_state"> element');
const stepContent = logToStateMatch[1];
// All 5 column names from quick.md Step 7 must be referenced in the step.
for (const col of QUICK_MD_STEP7_COLUMNS) {
assert.ok(
stepContent.toLowerCase().includes(col.toLowerCase()),
[
`fast.md log_to_state step does not reference column "${col}"`,
`Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`,
`(${QUICK_MD_STEP7_COL_COUNT}-column schema)`,
].join('\n')
);
}
assert.ok(
stepContent.includes('quick-tasks-append'),
[
'fast.md log_to_state step does not invoke the schema-aware quick-tasks-append helper.',
'Schema-awareness now lives in the gsd-tools quick-tasks-append subcommand',
`(appendQuickTaskRow, handling both the ${QUICK_MD_STEP7_COL_COUNT}-column quick.md Step 7 schema`,
`${QUICK_MD_STEP7_COLUMNS.join(', ')} and the 6-column with-status variant) —`,
'the step must delegate to it rather than hardcoding column names inline.',
].join('\n')
);
});
test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => {

View File

@@ -541,10 +541,15 @@ function completePhaseBody() {
// A roadmap with a progress table: 3 of 5 phases Complete → deriveProgressFromRoadmap
// returns { completedPhases: 3, totalPhases: 5 }.
// ADR-2143 (epic #2143): deriveProgressFromRoadmap now resolves this table via the
// markdown-table schema registry (TABLE_SCHEMAS.RoadmapProgress), which requires the
// exact canonical header (gsd-core/templates/roadmap.md); the 2nd column is named
// "Plans Complete" to match (its cell values here are unused free text, not M/N
// counts — no test in this file asserts totalPlans).
const ROADMAP_3_OF_5 = [
'## Roadmap',
'',
'| Phase | Title | Status | Completed |',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1 | A | Complete | 2026-01-01 |',
'| 2 | B | Complete | 2026-02-01 |',

View File

@@ -9487,13 +9487,20 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
// ─── Scenario A: deriveProgressFromRoadmap unit test ────────────────────────
//
// ADR-2143 (epic #2143) migrated deriveProgressFromRoadmap from position-based
// regexes to the markdown-table schema registry (TABLE_SCHEMAS.RoadmapProgress),
// which resolves the Progress table by exact column-name match. These fixtures'
// second column is renamed "Plans" -> "Plans Complete" to match the canonical
// header (gsd-core/templates/roadmap.md) the schema now requires; the assertions
// (999.x exclusion, Complete-row counting) are unchanged.
describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 2/2 | Complete | ✅ |',
'| 2. Beta | 1/2 | In Progress | |',
@@ -9518,7 +9525,7 @@ describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Alpha | 1/1 | Complete | ✅ |',
'| 2. Beta | 1/1 | Complete | ✅ |',
@@ -9542,7 +9549,7 @@ describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
const roadmap = [
'## Milestone v1.0: Test',
'',
'| Phase | Plans | Status | Completed |',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 999.1 Future A | 0/0 | Backlog | |',
'| 999.2 Future B | 0/0 | Backlog | |',
@@ -9721,9 +9728,13 @@ describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-group
});
test('ragged rows (more/fewer cells than the header) are handled without throwing', () => {
// The reader indexes cells positionally by header name, so an EXTRA trailing
// column is ignored and a SHORT row simply has absent Status/Plans cells
// (`cells[idx] ?? ''`) — neither should throw or corrupt the well-formed row.
// (#2242 review Fix 5 / ADR-2143 §3): deriveProgressFromRoadmap now resolves
// the Progress table via the markdown-table seam's parseMarkdownTable, which
// is fail-loud on ragged data rows by design — "ragged rows are errors, not
// silent" (src/markdown-table.cts) — rather than the pre-ADR-2143 reader's
// graceful cell-count degradation this test used to assert. A ragged row
// anywhere in the table now makes the WHOLE table unparseable, so the reader
// falls through to its existing (null) values instead of throwing.
const roadmap = [
'## Progress',
'',
@@ -9737,13 +9748,10 @@ describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-group
assert.doesNotThrow(() => {
result = deriveProgressFromRoadmap(roadmap);
}, 'ragged rows must not throw');
// Row 1: extra column ignored → counted, Complete, +2 plans.
// Row 2: short row → counted as a phase, but Status/Plans cells are absent so
// it is neither Complete nor plan-bearing.
assert.deepEqual(
result,
{ completedPhases: 1, totalPhases: 2, totalPlans: 2 },
`ragged rows must degrade gracefully to {1,2,2}, got ${JSON.stringify(result)}`,
{ completedPhases: null, totalPhases: null, totalPlans: null },
`a ragged-row table must fail loud to all-null (no throw), got ${JSON.stringify(result)}`,
);
});
});

View File

@@ -28,7 +28,7 @@
"execute-plan.md": 32655,
"explore.md": 10541,
"extract-learnings.md": 12893,
"fast.md": 4790,
"fast.md": 7613,
"forensics.md": 12531,
"graduation.md": 11622,
"health.md": 11868,
@@ -59,7 +59,7 @@
"pr-branch.md": 15963,
"profile-user.md": 21246,
"progress.md": 30599,
"quick.md": 50470,
"quick.md": 50699,
"reapply-patches.md": 20312,
"remove-phase.md": 8513,
"remove-workspace.md": 7551,