fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase (#1256)

* fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase

state begin-phase/complete-phase called stateReplaceField on the FULL file content, so its case-insensitive ^Status: plain-pattern matched the YAML frontmatter status: line first (no g flag) and never updated the body pipe-table Status cell; syncStateFrontmatter then re-derived the stale status from the unchanged body, freezing the frontmatter status. Fix: strip frontmatter before the body-field replacements (operate on body only), reassemble with frontmatter preserved, so the body Status cell updates and the frontmatter derives correctly — for inline AND pipe-table body formats. Also corrects the Current Position pipe-table else-branches (Status/Phase/Last-activity) to write bare, consistent cell values. Pipe-table Status is a supported body format (not rewritten to inline).

Closes #1255

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

* chore(#1255): add changeset for pipe-table state status fix (#1256)

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

* test(#1255): fold pipe-table regression into state.test.cjs + Windows-portable frontmatter regex

Per the 2026-06 audit, new tests/bug-NNNN-*.test.cjs files are banned (lint-regression-test-names) — folded the 7 #1255 regressions into tests/state.test.cjs and removed the standalone file + its lint-test-file-count allowlist entry. Also fixed the frontmatter assertions' /^---\n/ anchors to /^---\r?\n/ (windows-test-parity-guard frontmatterAnchorLiteralNewline).

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-06-14 22:42:28 -04:00
committed by GitHub
parent 3556450b0d
commit 2668fbbeb0
3 changed files with 406 additions and 33 deletions

View File

@@ -9,6 +9,7 @@
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { createFixture } = require('./fixtures/index.cjs');
@@ -3655,3 +3656,307 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1255 — begin/complete-phase advance status for pipe-table STATE.md
// ─────────────────────────────────────────────────────────────────────────────
/**
* Regression tests for bug #1255.
*
* `state begin-phase` / `state complete-phase` do not advance the frontmatter
* `status` when the body `Status` field is expressed as a pipe-table row
* (`| Status | Planning |`) instead of an inline key-value pair
* (`Status: Planning`).
*
* Root cause: `stateReplaceField(content, 'Status', ...)` is called with the
* full file content (frontmatter + body). The plain-text pattern
* (`^Status:\s*(.+)` with /im flag) matches `status: planning` in the YAML
* frontmatter block rather than the body pipe-table row. The pipe-table row
* is never updated. `syncStateFrontmatter` then re-derives from the body (which
* still says 'Planning') and the #1230 delta heuristic preserves the old
* frontmatter value ('planning'), so the status never advances to 'executing'.
*
* Fix: strip frontmatter before all body-field replacements in
* `cmdStateBeginPhase` and `cmdStateCompletePhase`, then reassemble.
*
* Additional bugs fixed (#1255 follow-up):
* 1. complete-phase Phase table cell had label-duplication: `Phase: 1 — COMPLETE`
* instead of bare `1 — COMPLETE`.
* 2. begin-phase and complete-phase Last-activity table branches wrote bare date
* instead of date + narrative (inconsistent with inline branch).
*/
function make1255TempProject(stateContent) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1255-'));
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal ROADMAP so buildStateFrontmatter can resolve phase counts
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
'# ROADMAP',
'',
'## Phase 1: setup:',
'- [ ] Step 1',
'',
].join('\n'), 'utf8');
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
return dir;
}
// STATE.md where Status lives entirely in pipe-table rows (no inline "Status: ..." anywhere)
// This is the form a hand-edited or legacy STATE.md might use, and is a
// supported body format (do NOT silently rewrite to inline).
const TABLE_STATUS_PLANNING_1255 = `---
gsd_state_version: '1.0'
status: planning
---
# Project State
## Configuration
| Current Phase | 1 |
| Current Phase Name | setup |
| Total Plans in Phase | 3 |
| Current Plan | 1 |
| Status | Planning |
| Last Activity | 2026-06-01 |
| Last Activity Description | Roadmap created |
## Current Position
| Phase | 1 (setup) |
| Plan | 1 of 3 |
| Status | Planning |
| Last activity | 2026-06-01 |
`;
// STATE.md with Status as pipe-table but execution already in progress (complete-phase scenario)
const TABLE_STATUS_EXECUTING_1255 = `---
gsd_state_version: '1.0'
status: executing
---
# Project State
## Configuration
| Current Phase | 1 |
| Current Phase Name | setup |
| Total Plans in Phase | 3 |
| Current Plan | 3 |
| Status | Executing Phase 1 |
| Last Activity | 2026-06-01 |
| Last Activity Description | Phase 1 execution started |
## Current Position
| Phase | 1 (setup) |
| Plan | 3 of 3 |
| Status | Executing Phase 1 |
| Last activity | 2026-06-01 |
`;
describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md', () => {
// begin-phase: planning → executing
test('begin-phase advances frontmatter status planning→executing when body Status is pipe-table', () => {
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
try {
const result = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
dir
);
assert.ok(result.success, `begin-phase failed: ${result.error || result.output}`);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
// Primary assertion: frontmatter status must advance to 'executing'
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after begin-phase');
const fm = fmMatch[1];
assert.ok(
/^status:\s*executing\s*$/m.test(fm),
`frontmatter status must be 'executing' after begin-phase on pipe-table STATUS; got frontmatter:\n${fm}`
);
} finally {
cleanup(dir);
}
});
// begin-phase: body pipe-table row must also be updated
test('begin-phase updates body pipe-table Status cell to Executing Phase N', () => {
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
try {
runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
dir
);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
// The pipe-table Status cell in the Configuration table must be updated
assert.ok(
/\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(after),
`body pipe-table Status cell must be updated to 'Executing Phase 1'; got:\n${after}`
);
} finally {
cleanup(dir);
}
});
// begin-phase: Current Position table cells — exact cell values
test('begin-phase updates Current Position pipe-table Status and Last activity cells correctly', () => {
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
try {
runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
dir
);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
// Extract the ## Current Position section only, to avoid matching Configuration rows
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(cpMatch, '## Current Position section must exist');
const cpSection = cpMatch[1];
// Status cell in Current Position: bare value, not prefixed
assert.ok(
/\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(cpSection),
`Current Position Status cell must be 'Executing Phase 1'; got Current Position:\n${cpSection}`
);
// Last activity cell must include date + narrative (not bare date)
assert.ok(
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 execution started\s*\|/i.test(cpSection),
`Current Position Last activity cell must include narrative '-- Phase 1 execution started'; got Current Position:\n${cpSection}`
);
} finally {
cleanup(dir);
}
});
// complete-phase: executing → completed
test('complete-phase advances frontmatter status executing→completed when body Status is pipe-table', () => {
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
try {
const result = runGsdTools(
['state', 'complete-phase', '--phase', '1'],
dir
);
assert.ok(result.success, `complete-phase failed: ${result.error || result.output}`);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
// Primary assertion: frontmatter status must be 'completed'
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after complete-phase');
const fm = fmMatch[1];
assert.ok(
/^status:\s*completed\s*$/m.test(fm),
`frontmatter status must be 'completed' after complete-phase on pipe-table STATUS; got frontmatter:\n${fm}`
);
} finally {
cleanup(dir);
}
});
// complete-phase: body pipe-table row must also be updated
test('complete-phase updates body pipe-table Status cell to Phase N complete', () => {
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
try {
runGsdTools(
['state', 'complete-phase', '--phase', '1'],
dir
);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
assert.ok(
/\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(after),
`body pipe-table Status cell must be updated to 'Phase 1 complete'; got:\n${after}`
);
} finally {
cleanup(dir);
}
});
// complete-phase: Current Position table cells — exact cell values (catches bugs 1 and 2)
test('complete-phase updates Current Position pipe-table Phase/Status/Last-activity cells correctly', () => {
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
try {
runGsdTools(
['state', 'complete-phase', '--phase', '1'],
dir
);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
// Extract the ## Current Position section only, to avoid matching Configuration rows
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(cpMatch, '## Current Position section must exist');
const cpSection = cpMatch[1];
// Bug 1: Phase cell must be bare '1 — COMPLETE', NOT 'Phase: 1 — COMPLETE'
assert.ok(
/\|\s*Phase\s*\|\s*1\s*—\s*COMPLETE\s*\|/.test(cpSection),
`Current Position Phase cell must be '1 — COMPLETE' (no 'Phase:' prefix in cell value); got Current Position:\n${cpSection}`
);
assert.ok(
!/\|\s*Phase\s*\|\s*Phase:\s*1/.test(cpSection),
`Current Position Phase cell must NOT contain 'Phase: 1' (label-duplication bug); got Current Position:\n${cpSection}`
);
// Status cell in Current Position: bare value
assert.ok(
/\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(cpSection),
`Current Position Status cell must be 'Phase 1 complete'; got Current Position:\n${cpSection}`
);
// Bug 2: Last activity cell must include date + narrative (not bare date)
assert.ok(
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 marked complete\s*\|/i.test(cpSection),
`Current Position Last activity cell must include narrative '-- Phase 1 marked complete'; got Current Position:\n${cpSection}`
);
} finally {
cleanup(dir);
}
});
// Regression guard: inline Status format must still work (existing behavior unchanged)
test('begin-phase still works correctly with inline Status: format (regression guard)', () => {
const inlineStateMd = `---
gsd_state_version: '1.0'
status: planning
---
# Project State
Current Phase: 1
Current Phase Name: setup
Total Plans in Phase: 3
Current Plan: 1
Status: Planning
Last Activity: 2026-06-01
Last Activity Description: Roadmap created
## Current Position
Phase: 1 (setup)
Plan: 1 of 3
Status: Planning
Last activity: 2026-06-01 -- Roadmap created
`;
const dir = make1255TempProject(inlineStateMd);
try {
const result = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
dir
);
assert.ok(result.success, `begin-phase failed on inline format: ${result.error || result.output}`);
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, 'must have frontmatter');
const fm = fmMatch[1];
assert.ok(
/^status:\s*executing\s*$/m.test(fm),
`inline Status: format: frontmatter status must be 'executing'; got:\n${fm}`
);
} finally {
cleanup(dir);
}
});
});