From e30f9370d38573dc8031bd1d4224fdeec84518ff Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 12 Jul 2026 10:27:55 -0400 Subject: [PATCH] test(#2133): execute fast.md log_to_state guard against both schemas Replaces the removed prose-regex test (bug-3805-*) that let the off-by-one ship green. Extracts the actual bash block deployed in fast.md's log_to_state step and EXECUTES it against real STATE.md fixtures, asserting on the filesystem result: a row is appended with a cell count matching the header for both the 5-column (non-validate) and 6-column (validate) schemas, and an unrecognized schema still skips with a warning. --- ...-2133-fast-md-log-to-state-schema.test.cjs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/fix-2133-fast-md-log-to-state-schema.test.cjs diff --git a/tests/fix-2133-fast-md-log-to-state-schema.test.cjs b/tests/fix-2133-fast-md-log-to-state-schema.test.cjs new file mode 100644 index 000000000..92452e841 --- /dev/null +++ b/tests/fix-2133-fast-md-log-to-state-schema.test.cjs @@ -0,0 +1,177 @@ +/** + * #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 os = require('node:os'); +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 . + * 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 = ''; + const stepStart = content.indexOf(stepTag); + assert.notEqual(stepStart, -1, 'fast.md must contain a log_to_state step'); + const stepEnd = content.indexOf('', 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)); + let stdout = ''; + try { + stdout = execFileSync('bash', ['-c', bashBlock], { + cwd: tmpDir, + env: { ...process.env, TASK: TASK_DESC }, + encoding: 'utf8', + }); + } finally { + // cleanup deferred to caller via t.after where bound + } + 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 is derived with NF-2, not NF-1 (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. + assert.match(bashBlock, /NF-2/, 'column count must use NF-2 (real columns), not NF-1'); + assert.doesNotMatch(bashBlock, /NF-1/, 'the off-by-one NF-1 formula must be gone'); + }); + + 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'); + }); +});