* fix(#2376): emit absolute subagent-facing init/state paths Make init.* and state.* path fields absolute rather than cwd-relative so subagent prompts resolve correctly regardless of working directory. Adds intel_dir/conflicts_path/requirements_path/roadmap_path/state_path to cmdInitIngestDocs, an absolute debug_dir to cmdStateLoad, and replaces bare .planning/... literals in 12 workflow Agent() prompt blocks with the absolute init-JSON path fields. Includes decoy-cwd regression tests and realpath'd tmpdir fixtures for macOS. Squashed rebase of the #2376 commit series onto a fresh origin/next (previous merge ee25543a1 was against a now-stale next). * chore(#2376): add changeset * chore(#2376): regenerate golden fixtures + workflow size baseline Regenerated after rebasing the absolute-path fix onto current next (picks up #2351's run-with-timeout content in execute-phase.md too). * fix(#2376): trim execute-phase.md redundancy to stay under the size margin * chore(#2376): regenerate golden/size baseline after rebase onto next
10664 lines
431 KiB
JavaScript
10664 lines
431 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||
// runtime loads — testing text content tests the deployed contract.
|
||
|
||
/**
|
||
* GSD Tools Tests - State
|
||
*/
|
||
|
||
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, createTempDir, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { createFixture } = require('./fixtures/index.cjs');
|
||
|
||
function writePassedVerification(tmpDir, phaseDirName, paddedPhase) {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', phaseDirName, `${paddedPhase}-VERIFICATION.md`),
|
||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
|
||
);
|
||
}
|
||
|
||
describe('state-snapshot command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('missing STATE.md returns error', () => {
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file');
|
||
});
|
||
|
||
test('extracts basic fields from STATE.md', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Current Phase Name:** API Layer
|
||
**Total Phases:** 6
|
||
**Current Plan:** 03-02
|
||
**Total Plans in Phase:** 3
|
||
**Status:** In progress
|
||
**Progress:** 45%
|
||
**Last Activity:** 2024-01-15
|
||
**Last Activity Description:** Completed 03-01-PLAN.md
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_phase, '03', 'current phase extracted');
|
||
assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted');
|
||
assert.strictEqual(output.total_phases, 6, 'total phases extracted');
|
||
assert.strictEqual(output.current_plan, '03-02', 'current plan extracted');
|
||
assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted');
|
||
assert.strictEqual(output.status, 'In progress', 'status extracted');
|
||
assert.strictEqual(output.progress_percent, 45, 'progress extracted');
|
||
assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted');
|
||
});
|
||
|
||
test('extracts decisions table', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 01
|
||
|
||
## Decisions Made
|
||
|
||
| Phase | Decision | Rationale |
|
||
|-------|----------|-----------|
|
||
| 01 | Use Prisma | Better DX than raw SQL |
|
||
| 02 | JWT auth | Stateless authentication |
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions');
|
||
assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase');
|
||
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary');
|
||
assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale');
|
||
});
|
||
|
||
test('extracts blockers list', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 03
|
||
|
||
## Blockers
|
||
|
||
- Waiting for API credentials
|
||
- Need design review for dashboard
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.blockers, [
|
||
'Waiting for API credentials',
|
||
'Need design review for dashboard',
|
||
], 'blockers extracted');
|
||
});
|
||
|
||
test('extracts session continuity info', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 03
|
||
|
||
## Session
|
||
|
||
**Last Date:** 2024-01-15
|
||
**Stopped At:** Phase 3, Plan 2, Task 1
|
||
**Resume File:** .planning/phases/03-api/03-02-PLAN.md
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted');
|
||
assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted');
|
||
assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted');
|
||
});
|
||
|
||
test('handles paused_at field', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted');
|
||
});
|
||
|
||
describe('--cwd override', () => {
|
||
let outsideDir;
|
||
|
||
beforeEach(() => {
|
||
outsideDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-outside-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(outsideDir);
|
||
});
|
||
|
||
test('supports --cwd override when command runs outside project root', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Session State
|
||
|
||
**Current Phase:** 03
|
||
**Status:** Ready to plan
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools(`state-snapshot --cwd "${tmpDir}"`, outsideDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_phase, '03', 'should read STATE.md from overridden cwd');
|
||
assert.strictEqual(output.status, 'Ready to plan', 'should parse status from overridden cwd');
|
||
});
|
||
});
|
||
|
||
test('returns error for invalid --cwd path', () => {
|
||
const invalid = path.join(tmpDir, 'does-not-exist');
|
||
const result = runGsdTools(`state-snapshot --cwd "${invalid}"`, tmpDir);
|
||
assert.ok(!result.success, 'should fail for invalid --cwd');
|
||
assert.ok(result.error.includes('Invalid --cwd'), 'error should mention invalid --cwd');
|
||
});
|
||
});
|
||
|
||
// ─── Regression: #3265 — frontmatter wins over bold-body cell ─────────────
|
||
|
||
describe('state-snapshot — bug #3265 frontmatter precedence', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('returns frontmatter status, not **Status:** value embedded in a body table cell', () => {
|
||
// Reproduce the collision: frontmatter says "executing", but the body
|
||
// contains a Markdown table cell with "**Status:** to ✅ COMPLETE ..."
|
||
// which stateExtractField (bold pattern) would match before the YAML line.
|
||
const stateContent = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: executing',
|
||
'current_plan: 19.5-05',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Recent Quick Tasks',
|
||
'',
|
||
'| Date | Task | Notes |',
|
||
'|------|------|-------|',
|
||
'| 2026-05-01 | Reopened Plan 19.5-05. **Status:** to ✅ COMPLETE | done |',
|
||
'',
|
||
'**Current Phase:** 19',
|
||
'**Current Plan:** archived-lane',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// Frontmatter status must win over the table cell's **Status:** match
|
||
assert.strictEqual(output.status, 'executing', 'frontmatter status beats body table cell');
|
||
});
|
||
|
||
test('returns frontmatter current_plan, not bold body value when both present', () => {
|
||
const stateContent = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: executing',
|
||
'current_plan: 19.5-05',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 19',
|
||
'**Current Plan:** archived-lane',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_plan, '19.5-05', 'frontmatter current_plan beats body bold value');
|
||
});
|
||
|
||
test('falls back to body extraction when no frontmatter block is present', () => {
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 07',
|
||
'**Status:** paused',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// No frontmatter — body extraction must still work
|
||
assert.strictEqual(output.status, 'paused', 'body extraction works without frontmatter');
|
||
assert.strictEqual(output.current_phase, '07', 'body extraction works without frontmatter');
|
||
});
|
||
});
|
||
|
||
describe('state mutation commands', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('add-decision preserves dollar amounts without corrupting Decisions section', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
## Decisions
|
||
No decisions yet.
|
||
|
||
## Blockers
|
||
None
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '11-01', '--summary', 'Benchmark prices moved from $0.50 to $2.00 to $5.00', '--rationale', 'track cost growth'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(
|
||
state,
|
||
/- \[Phase 11-01\]: Benchmark prices moved from \$0\.50 to \$2\.00 to \$5\.00 — track cost growth/,
|
||
'decision entry should preserve literal dollar values'
|
||
);
|
||
assert.strictEqual((state.match(/^## Decisions$/gm) || []).length, 1, 'Decisions heading should not be duplicated');
|
||
assert.ok(!state.includes('No decisions yet.'), 'placeholder should be removed');
|
||
});
|
||
|
||
test('add-blocker preserves dollar strings without corrupting Blockers section', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
## Decisions
|
||
None
|
||
|
||
## Blockers
|
||
None
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools(['state', 'add-blocker', '--text', 'Waiting on vendor quote $1.00 before approval'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(state, /- Waiting on vendor quote \$1\.00 before approval/, 'blocker entry should preserve literal dollar values');
|
||
assert.strictEqual((state.match(/^## Blockers$/gm) || []).length, 1, 'Blockers heading should not be duplicated');
|
||
});
|
||
|
||
test('add-decision supports file inputs to preserve shell-sensitive dollar text', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
## Decisions
|
||
No decisions yet.
|
||
|
||
## Blockers
|
||
None
|
||
`
|
||
);
|
||
|
||
const summaryPath = path.join(tmpDir, 'decision-summary.txt');
|
||
const rationalePath = path.join(tmpDir, 'decision-rationale.txt');
|
||
fs.writeFileSync(summaryPath, 'Price tiers: $0.50, $2.00, else $5.00\n');
|
||
fs.writeFileSync(rationalePath, 'Keep exact currency literals for budgeting\n');
|
||
|
||
const result = runGsdTools(
|
||
`state add-decision --phase 11-02 --summary-file "${summaryPath}" --rationale-file "${rationalePath}"`,
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(
|
||
state,
|
||
/- \[Phase 11-02\]: Price tiers: \$0\.50, \$2\.00, else \$5\.00 — Keep exact currency literals for budgeting/,
|
||
'file-based decision input should preserve literal dollar values'
|
||
);
|
||
});
|
||
|
||
test('add-blocker supports --text-file for shell-sensitive text', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
## Decisions
|
||
None
|
||
|
||
## Blockers
|
||
None
|
||
`
|
||
);
|
||
|
||
const blockerPath = path.join(tmpDir, 'blocker.txt');
|
||
fs.writeFileSync(blockerPath, 'Vendor quote updated from $1.00 to $2.00 pending approval\n');
|
||
|
||
const result = runGsdTools(`state add-blocker --text-file "${blockerPath}"`, tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(state, /- Vendor quote updated from \$1\.00 to \$2\.00 pending approval/);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state json command (machine-readable STATE.md frontmatter)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('state json command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('missing STATE.md returns error', () => {
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file');
|
||
});
|
||
|
||
test('builds frontmatter on-the-fly from body when no frontmatter exists', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 05
|
||
**Current Phase Name:** Deployment
|
||
**Total Phases:** 8
|
||
**Current Plan:** 05-03
|
||
**Total Plans in Phase:** 4
|
||
**Status:** In progress
|
||
**Progress:** 60%
|
||
**Last Activity:** 2026-01-20
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.gsd_state_version, '1.0', 'should have version 1.0');
|
||
assert.strictEqual(output.current_phase, '05', 'current phase extracted');
|
||
assert.strictEqual(output.current_phase_name, 'Deployment', 'phase name extracted');
|
||
assert.strictEqual(output.current_plan, '05-03', 'current plan extracted');
|
||
assert.strictEqual(output.status, 'executing', 'status normalized to executing');
|
||
assert.ok(output.last_updated, 'should have last_updated timestamp');
|
||
assert.strictEqual(output.last_activity, '2026-01-20', 'last activity extracted');
|
||
assert.ok(output.progress, 'should have progress object');
|
||
assert.strictEqual(output.progress.percent, 60, 'progress percent extracted');
|
||
});
|
||
|
||
test('reads existing frontmatter when present', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---
|
||
gsd_state_version: 1.0
|
||
current_phase: 03
|
||
status: paused
|
||
stopped_at: Plan 2 of Phase 3
|
||
---
|
||
|
||
# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Status:** Paused
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.gsd_state_version, '1.0', 'version from frontmatter');
|
||
assert.strictEqual(output.current_phase, '03', 'phase from frontmatter');
|
||
assert.strictEqual(output.status, 'paused', 'status from frontmatter');
|
||
assert.strictEqual(output.stopped_at, 'Plan 2 of Phase 3', 'stopped_at from frontmatter');
|
||
});
|
||
|
||
test('normalizes various status values', () => {
|
||
const statusTests = [
|
||
{ input: 'In progress', expected: 'executing' },
|
||
{ input: 'Ready to execute', expected: 'executing' },
|
||
{ input: 'Paused at Plan 3', expected: 'paused' },
|
||
{ input: 'Ready to plan', expected: 'planning' },
|
||
{ input: 'Phase complete — ready for verification', expected: 'verifying' },
|
||
{ input: 'Milestone complete', expected: 'completed' },
|
||
{ input: 'All phases complete', expected: 'completed' },
|
||
];
|
||
|
||
for (const { input, expected } of statusTests) {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# State\n\n**Current Phase:** 01\n**Status:** ${input}\n`
|
||
);
|
||
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `Command failed for status "${input}": ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.status, expected, `"${input}" should normalize to "${expected}"`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// STATE.md frontmatter sync (write operations add frontmatter)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('STATE.md frontmatter sync', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state update adds frontmatter to STATE.md', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 02
|
||
**Status:** Ready to execute
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state update Status "Executing Plan 1"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(content.startsWith('---\n'), 'should start with frontmatter delimiter');
|
||
assert.ok(content.includes('gsd_state_version: 1.0'), 'should have version field');
|
||
assert.ok(content.includes('current_phase: 02'), 'frontmatter should have current phase');
|
||
assert.ok(content.includes('**Current Phase:** 02'), 'body field should be preserved');
|
||
assert.ok(content.includes('**Status:** Executing Plan 1'), 'updated field in body');
|
||
});
|
||
|
||
test('state patch adds frontmatter', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 04
|
||
**Status:** Planning
|
||
**Current Plan:** 04-01
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 04-02', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(content.startsWith('---\n'), 'should have frontmatter after patch');
|
||
});
|
||
|
||
test('frontmatter is idempotent on multiple writes', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 01
|
||
**Status:** Ready to execute
|
||
`
|
||
);
|
||
|
||
runGsdTools('state update Status "In progress"', tmpDir);
|
||
runGsdTools('state update Status "Paused"', tmpDir);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
const delimiterCount = (content.match(/^---$/gm) || []).length;
|
||
assert.strictEqual(delimiterCount, 2, 'should have exactly one frontmatter block (2 delimiters)');
|
||
assert.ok(content.includes('status: paused'), 'frontmatter should reflect latest status');
|
||
});
|
||
|
||
test('preserves frontmatter status when body Status field is missing', () => {
|
||
// Simulate: frontmatter has status: executing, but body lost Status: field
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---
|
||
status: executing
|
||
milestone: v1.0
|
||
---
|
||
|
||
# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Current Plan:** 03-02
|
||
`
|
||
);
|
||
|
||
// Any writeStateMd triggers syncStateFrontmatter — use state update on a field that exists
|
||
runGsdTools('state update "Current Plan" "03-03"', tmpDir);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(content.includes('status: executing'), 'should preserve existing status, not overwrite with unknown');
|
||
assert.ok(!content.includes('status: unknown'), 'should not contain unknown status');
|
||
});
|
||
|
||
test('#2202: preserves unknown frontmatter keys the schema does not own', () => {
|
||
// Regression: a mutating verb rewrites STATE.md via syncStateFrontmatter,
|
||
// which rebuilds frontmatter from the body + schema. Before #2202 it dropped
|
||
// any frontmatter key the schema does not own; custom/tooling keys must
|
||
// survive every write.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---
|
||
status: executing
|
||
milestone: v1.0
|
||
custom_tracking_id: ABC-123
|
||
team: platform
|
||
---
|
||
|
||
# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Current Plan:** 03-02
|
||
`
|
||
);
|
||
|
||
// Any writeStateMd triggers syncStateFrontmatter.
|
||
runGsdTools('state update "Current Plan" "03-03"', tmpDir);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(content, /custom_tracking_id: ABC-123/, 'unknown key custom_tracking_id must be preserved');
|
||
assert.match(content, /team: platform/, 'unknown key team must be preserved');
|
||
// Schema-owned keys still win / survive alongside the carried-forward keys.
|
||
assert.ok(content.includes('status: executing'), 'schema-owned status still preserved');
|
||
});
|
||
|
||
test('round-trip: write then read via state json', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State
|
||
|
||
**Current Phase:** 07
|
||
**Current Phase Name:** Production
|
||
**Total Phases:** 10
|
||
**Status:** In progress
|
||
**Current Plan:** 07-05
|
||
**Progress:** 70%
|
||
`
|
||
);
|
||
|
||
runGsdTools('state update Status "Executing Plan 5"', tmpDir);
|
||
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_phase, '07', 'round-trip: phase preserved');
|
||
assert.strictEqual(output.current_phase_name, 'Production', 'round-trip: phase name preserved');
|
||
assert.strictEqual(output.status, 'executing', 'round-trip: status normalized');
|
||
assert.ok(output.last_updated, 'round-trip: timestamp present');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// stateExtractField and stateReplaceField helpers
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('../gsd-core/bin/lib/state.cjs');
|
||
|
||
describe('stateExtractField and stateReplaceField helpers', () => {
|
||
// stateExtractField tests
|
||
|
||
test('extracts simple field value', () => {
|
||
const content = '# State\n\n**Status:** In progress\n';
|
||
const result = stateExtractField(content, 'Status');
|
||
assert.strictEqual(result, 'In progress', 'should extract simple field value');
|
||
});
|
||
|
||
test('extracts field with colon in value', () => {
|
||
const content = '# State\n\n**Last Activity:** 2024-01-15 — Completed plan\n';
|
||
const result = stateExtractField(content, 'Last Activity');
|
||
assert.strictEqual(result, '2024-01-15 — Completed plan', 'should return full value after field pattern');
|
||
});
|
||
|
||
test('returns null for missing field', () => {
|
||
const content = '# State\n\n**Phase:** 03\n';
|
||
const result = stateExtractField(content, 'Status');
|
||
assert.strictEqual(result, null, 'should return null when field not present');
|
||
});
|
||
|
||
test('is case-insensitive on field name', () => {
|
||
const content = '# State\n\n**status:** Active\n';
|
||
const result = stateExtractField(content, 'Status');
|
||
assert.strictEqual(result, 'Active', 'should match field name case-insensitively');
|
||
});
|
||
|
||
// stateReplaceField tests
|
||
|
||
test('replaces field value', () => {
|
||
const content = '# State\n\n**Status:** Old\n';
|
||
const result = stateReplaceField(content, 'Status', 'New');
|
||
assert.ok(result !== null, 'should return updated content, not null');
|
||
assert.ok(result.includes('**Status:** New'), 'output should contain updated field value');
|
||
assert.ok(!result.includes('**Status:** Old'), 'output should not contain old field value');
|
||
});
|
||
|
||
test('returns null when field not found', () => {
|
||
const content = '# State\n\n**Phase:** 03\n';
|
||
const result = stateReplaceField(content, 'Status', 'New');
|
||
assert.strictEqual(result, null, 'should return null when field not present');
|
||
});
|
||
|
||
test('preserves surrounding content', () => {
|
||
const content = [
|
||
'# Project State',
|
||
'',
|
||
'**Phase:** 03',
|
||
'**Status:** Old',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Notes',
|
||
'Some notes here.',
|
||
].join('\n');
|
||
|
||
const result = stateReplaceField(content, 'Status', 'New');
|
||
assert.ok(result !== null, 'should return updated content');
|
||
assert.ok(result.includes('**Phase:** 03'), 'Phase line should be unchanged');
|
||
assert.ok(result.includes('**Status:** New'), 'Status should be updated');
|
||
assert.ok(result.includes('**Last Activity:** 2024-01-15'), 'Last Activity line should be unchanged');
|
||
assert.ok(result.includes('## Notes'), 'Notes heading should be unchanged');
|
||
assert.ok(result.includes('Some notes here.'), 'Notes content should be unchanged');
|
||
});
|
||
|
||
test('round-trip: extract then replace then extract', () => {
|
||
const content = '# State\n\n**Phase:** 3\n';
|
||
const extracted = stateExtractField(content, 'Phase');
|
||
assert.strictEqual(extracted, '3', 'initial extract should return "3"');
|
||
|
||
const updated = stateReplaceField(content, 'Phase', '4');
|
||
assert.ok(updated !== null, 'replace should succeed');
|
||
|
||
const reExtracted = stateExtractField(updated, 'Phase');
|
||
assert.strictEqual(reExtracted, '4', 'extract after replace should return "4"');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// stateReplaceFieldWithFallback — consolidated fallback helper
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('stateReplaceFieldWithFallback', () => {
|
||
test('replaces primary field when present', () => {
|
||
const content = '# State\n\n**Status:** Old\n';
|
||
const result = stateReplaceFieldWithFallback(content, 'Status', null, 'New');
|
||
assert.ok(result.includes('**Status:** New'));
|
||
});
|
||
|
||
test('falls back to secondary field when primary not found', () => {
|
||
const content = '# State\n\nLast activity: 2024-01-01\n';
|
||
const result = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', '2025-03-19');
|
||
assert.ok(result.includes('Last activity: 2025-03-19'), 'should update fallback field');
|
||
});
|
||
|
||
test('returns content unchanged when neither field matches', () => {
|
||
const content = '# State\n\n**Phase:** 3\n';
|
||
let warning = '';
|
||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||
process.stderr.write = (chunk) => {
|
||
warning += String(chunk);
|
||
return true;
|
||
};
|
||
let result;
|
||
try {
|
||
result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New');
|
||
} finally {
|
||
process.stderr.write = origErrWrite;
|
||
}
|
||
assert.strictEqual(result, content, 'content should be unchanged');
|
||
assert.match(warning, /STATE\.md field "Status"/, 'missing field warning should be emitted');
|
||
});
|
||
|
||
test('prefers primary over fallback when both exist', () => {
|
||
const content = '# State\n\n**Status:** Old\nStatus: Also old\n';
|
||
const result = stateReplaceFieldWithFallback(content, 'Status', 'Status', 'New');
|
||
// Bold format is tried first by stateReplaceField
|
||
assert.ok(result.includes('**Status:** New'), 'should replace bold (primary) format');
|
||
});
|
||
|
||
test('works with plain format fields', () => {
|
||
const content = '# State\n\nPhase: 1 of 3 (Foundation)\nStatus: In progress\nPlan: 01-01\n';
|
||
let updated = stateReplaceFieldWithFallback(content, 'Status', null, 'Complete');
|
||
assert.ok(updated.includes('Status: Complete'), 'should update plain Status');
|
||
updated = stateReplaceFieldWithFallback(updated, 'Current Plan', 'Plan', 'Not started');
|
||
assert.ok(updated.includes('Plan: Not started'), 'should fall back to Plan field');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdStateLoad, cmdStateGet, cmdStatePatch, cmdStateUpdate CLI tests
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('cmdStateLoad (state load)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('returns config and state when STATE.md exists', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ mode: 'yolo' })
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n'
|
||
);
|
||
|
||
const result = runGsdTools('state load', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.state_exists, true, 'state_exists should be true');
|
||
assert.strictEqual(output.config_exists, true, 'config_exists should be true');
|
||
assert.strictEqual(output.roadmap_exists, true, 'roadmap_exists should be true');
|
||
assert.ok(output.state_raw.includes('**Status:** Active'), 'state_raw should contain STATE.md content');
|
||
});
|
||
|
||
test('returns state_exists false when STATE.md missing', () => {
|
||
const result = runGsdTools('state load', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.state_exists, false, 'state_exists should be false');
|
||
assert.strictEqual(output.state_raw, '', 'state_raw should be empty string');
|
||
});
|
||
|
||
test('returns raw key=value format with --raw flag', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ mode: 'yolo' })
|
||
);
|
||
|
||
const result = runGsdTools('state load --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
assert.ok(result.output.includes('state_exists=true'), 'raw output should include state_exists=true');
|
||
assert.ok(result.output.includes('config_exists=true'), 'raw output should include config_exists=true');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// #2376: cmdStateLoad's *_dir/*_path fields must resolve regardless of the
|
||
// calling process's own cwd, not just the orchestrator's — debug.md has no
|
||
// init.* call of its own and reads debug_dir from `state load` to build
|
||
// debug_file_path for its gsd-debug-session-manager spawns instead of
|
||
// hardcoding '.planning/debug/{slug}.md'. See tests/init.test.cjs's matching
|
||
// '#2376 — init.* path fields resolve...' describe block for the established
|
||
// pattern (spawn gsd-tools with its OS-level process cwd pointed at an
|
||
// unrelated decoy directory while passing the real project root via --cwd).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#2376 — state load emits absolute debug_dir regardless of process cwd', () => {
|
||
let projectDir;
|
||
let decoyDir;
|
||
|
||
beforeEach(() => {
|
||
projectDir = createFixture();
|
||
decoyDir = createTempDir('gsd-2376-decoy-');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(projectDir);
|
||
cleanup(decoyDir);
|
||
});
|
||
|
||
test('state load emits absolute debug_dir field that resolves from a different process cwd (previously absent)', () => {
|
||
fs.mkdirSync(path.join(projectDir, '.planning', 'debug'), { recursive: true });
|
||
|
||
const result = runGsdTools(['state', 'load', '--cwd', projectDir], decoyDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok('debug_dir' in output, 'cmdStateLoad must now emit debug_dir (#2376)');
|
||
assert.ok(path.isAbsolute(output.debug_dir), `debug_dir must be absolute, got: "${output.debug_dir}"`);
|
||
assert.ok(fs.existsSync(output.debug_dir), `debug_dir must resolve to the real directory: "${output.debug_dir}"`);
|
||
});
|
||
});
|
||
|
||
describe('cmdStateGet (state get)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('returns full content when no section specified', () => {
|
||
const stateContent = '# Project State\n\n**Status:** Active\n**Phase:** 03\n';
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||
|
||
const result = runGsdTools('state get', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.content !== undefined, 'output should have content field');
|
||
assert.ok(output.content.includes('**Status:** Active'), 'content should include full STATE.md text');
|
||
});
|
||
|
||
test('extracts bold field value', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
|
||
const result = runGsdTools('state get Status', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output['Status'], 'Active', 'should extract Status field value');
|
||
});
|
||
|
||
test('extracts markdown section content', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n\n## Blockers\n\n- item1\n- item2\n'
|
||
);
|
||
|
||
const result = runGsdTools('state get Blockers', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output['Blockers'] !== undefined, 'should have Blockers key in output');
|
||
assert.ok(output['Blockers'].includes('item1'), 'section content should include item1');
|
||
assert.ok(output['Blockers'].includes('item2'), 'section content should include item2');
|
||
});
|
||
|
||
test('returns error for nonexistent field', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
|
||
const result = runGsdTools('state get Missing', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0 even for missing field: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.toLowerCase().includes('not found'), 'error should mention "not found"');
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state get Status', tmpDir);
|
||
assert.ok(!result.success, 'command should fail when STATE.md is missing');
|
||
assert.ok(
|
||
result.error.includes('STATE.md') || result.output.includes('STATE.md'),
|
||
'error message should mention STATE.md'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('cmdStatePatch and cmdStateUpdate (state patch, state update)', () => {
|
||
let tmpDir;
|
||
const stateMd = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 03',
|
||
'**Status:** In progress',
|
||
'**Last Activity:** 2024-01-15',
|
||
].join('\n') + '\n';
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state patch updates multiple fields at once', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
|
||
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
|
||
});
|
||
|
||
test('state patch accepts JSON object input from workflows', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools([
|
||
'query',
|
||
'state.patch',
|
||
JSON.stringify({
|
||
Status: 'Complete',
|
||
'Current Phase': '04',
|
||
}),
|
||
], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepEqual(output.updated.sort(), ['Current Phase', 'Status'].sort());
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
|
||
assert.ok(updated.includes('**Current Phase:** 04'), 'Current Phase should be updated to 04');
|
||
});
|
||
|
||
test('state patch reports failed fields that do not exist', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state patch --Status Done --Missing value', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated should be an array');
|
||
assert.ok(output.updated.includes('Status'), 'Status should be in updated list');
|
||
assert.ok(Array.isArray(output.failed), 'failed should be an array');
|
||
assert.ok(output.failed.includes('Missing'), 'Missing should be in failed list');
|
||
});
|
||
|
||
test('state update changes a single field', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state update Status "Phase complete"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.updated, true, 'updated should be true');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('**Status:** Phase complete'), 'Status should be updated');
|
||
assert.ok(updated.includes('**Current Phase:** 03'), 'Current Phase should be unchanged');
|
||
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
|
||
});
|
||
|
||
test('state update reports field not found', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state update Missing value', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0 for not-found field: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.updated, false, 'updated should be false');
|
||
assert.ok(output.reason !== undefined, 'should include a reason');
|
||
});
|
||
|
||
test('state update returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state update Status value', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.updated, false, 'updated should be false');
|
||
assert.ok(
|
||
output.reason.includes('STATE.md'),
|
||
'reason should mention STATE.md'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdStateAdvancePlan, cmdStateRecordMetric, cmdStateUpdateProgress
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('cmdStateAdvancePlan (state advance-plan)', () => {
|
||
let tmpDir;
|
||
|
||
const advanceFixture = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Plan:** 1',
|
||
'**Total Plans in Phase:** 3',
|
||
'**Status:** Executing',
|
||
'**Last Activity:** 2024-01-10',
|
||
].join('\n') + '\n';
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('advances plan counter when not on last plan', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), advanceFixture);
|
||
|
||
const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z');
|
||
const PINNED_DATE = '2020-06-15';
|
||
const result = runGsdTools('state advance-plan', tmpDir, {
|
||
GSD_TEST_MODE: '1',
|
||
GSD_NOW_MS: String(PINNED_MS),
|
||
});
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.advanced, true, 'advanced should be true');
|
||
assert.strictEqual(output.previous_plan, 1, 'previous_plan should be 1');
|
||
assert.strictEqual(output.current_plan, 2, 'current_plan should be 2');
|
||
assert.strictEqual(output.total_plans, 3, 'total_plans should be 3');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('**Current Plan:** 2'), 'Current Plan should be updated to 2');
|
||
assert.ok(updated.includes('**Status:** Ready to execute'), 'Status should be Ready to execute');
|
||
assert.ok(
|
||
updated.includes(`**Last Activity:** ${PINNED_DATE}`),
|
||
`Last Activity should be the pinned date ${PINNED_DATE}`,
|
||
);
|
||
});
|
||
|
||
test('marks phase complete on last plan', () => {
|
||
const lastPlanFixture = advanceFixture.replace('**Current Plan:** 1', '**Current Plan:** 3');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lastPlanFixture);
|
||
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.advanced, false, 'advanced should be false');
|
||
assert.strictEqual(output.reason, 'last_plan', 'reason should be last_plan');
|
||
assert.strictEqual(output.status, 'ready_for_verification', 'status should be ready_for_verification');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||
});
|
||
|
||
test('returns error when plan fields not parseable', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.toLowerCase().includes('cannot parse'), 'error should mention Cannot parse');
|
||
});
|
||
|
||
test('advances plan in compound "Plan: X of Y" format', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\nPlan: 2 of 5 in current phase\nStatus: In progress\nLast activity: 2025-01-01\n`
|
||
);
|
||
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.advanced, true, 'advanced should be true');
|
||
assert.strictEqual(output.previous_plan, 2);
|
||
assert.strictEqual(output.current_plan, 3);
|
||
assert.strictEqual(output.total_plans, 5);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('Plan: 3 of 5 in current phase'),
|
||
'should preserve compound format with updated plan number');
|
||
assert.ok(updated.includes('Status: Ready to execute'),
|
||
'Status should be updated');
|
||
});
|
||
|
||
test('marks phase complete on last plan in compound format', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\nPlan: 3 of 3 in current phase\nStatus: In progress\nLast activity: 2025-01-01\n`
|
||
);
|
||
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.advanced, false);
|
||
assert.strictEqual(output.reason, 'last_plan');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
|
||
});
|
||
});
|
||
|
||
describe('cmdStateRecordMetric (state record-metric)', () => {
|
||
let tmpDir;
|
||
|
||
const metricsFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Performance Metrics',
|
||
'',
|
||
'| Plan | Duration | Tasks | Files |',
|
||
'|------|----------|-------|-------|',
|
||
'| Phase 1 P1 | 3min | 2 tasks | 3 files |',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('appends metric row to existing table', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), metricsFixture);
|
||
|
||
const result = runGsdTools('state record-metric --phase 2 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('| Phase 2 P1 | 5min | 3 tasks | 4 files |'), 'new row should be present');
|
||
assert.ok(updated.includes('| Phase 1 P1 | 3min | 2 tasks | 3 files |'), 'existing row should still be present');
|
||
});
|
||
|
||
// #2245 Blocker 2: a RAGGED sibling data row (a hand-edited stray/extra
|
||
// pipe) in the existing Performance Metrics table used to fail the
|
||
// whole-table `parseMarkdownTable` gate, which fell through to the
|
||
// "section absent (or malformed)" scaffold branch and appended a SECOND
|
||
// "## Performance Metrics" heading — compounding on every per-plan
|
||
// record-metric call. The append must be ragged-tolerant: it locates the
|
||
// table's last existing row and splices the new row after it WITHOUT
|
||
// requiring every sibling row to parse cleanly, and must never introduce a
|
||
// duplicate heading when a (possibly ragged) table already exists.
|
||
test('#2245 appends into a RAGGED existing table without duplicating the heading', () => {
|
||
const raggedFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Performance Metrics',
|
||
'',
|
||
'| Plan | Duration | Tasks | Files |',
|
||
'|------|----------|-------|-------|',
|
||
'| Phase 1 P1 | 3min | 2 tasks | 3 files |',
|
||
'| Phase 1 P2 | 4min | 3 tasks | 5 files | extra |',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), raggedFixture);
|
||
|
||
const result = runGsdTools('state record-metric --phase 2 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
assert.ok(!output.created, 'created must be absent/false — an existing (ragged) section must not be treated as auto-created');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
const headingMatches = updated.match(/^## Performance Metrics\s*$/gim) || [];
|
||
assert.strictEqual(headingMatches.length, 1, `exactly ONE "## Performance Metrics" heading expected, got ${headingMatches.length}:\n${updated}`);
|
||
assert.ok(updated.includes('| Phase 1 P2 | 4min | 3 tasks | 5 files | extra |'), 'existing ragged row must be preserved verbatim');
|
||
assert.ok(updated.includes('| Phase 1 P1 | 3min | 2 tasks | 3 files |'), 'existing clean row should still be present');
|
||
assert.ok(updated.includes('| Phase 2 P1 | 5min | 3 tasks | 4 files |'), 'new row should be appended into the existing table');
|
||
});
|
||
|
||
// #2245/#2143: a live STATE.md's "## Performance Metrics" section also
|
||
// carries the "By Phase" velocity table (gsd-core/templates/state.md:48,
|
||
// `| Phase | Plans | Total | Avg/Plan |`), which the prior "first table in
|
||
// the section" targeting polluted with a mismatched per-plan row on EVERY
|
||
// plan completion (execute-plan.md:414 calls record-metric per-plan). The
|
||
// command must target ITS OWN `| Plan | Duration | Tasks | Files |` table
|
||
// specifically, self-creating one when the section exists but doesn't
|
||
// carry it yet.
|
||
test('#2245/#2143: record-metric does not pollute the By-Phase velocity table (targets its own metrics table)', () => {
|
||
const byPhaseFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Performance Metrics',
|
||
'',
|
||
'**Velocity:**',
|
||
'- Total plans completed: 0',
|
||
'- Average duration: 0 min',
|
||
'- Total execution time: 0.0 hours',
|
||
'',
|
||
'**By Phase:**',
|
||
'',
|
||
'| Phase | Plans | Total | Avg/Plan |',
|
||
'|-------|-------|-------|----------|',
|
||
'| - | - | - | - |',
|
||
'',
|
||
'**Recent Trend:**',
|
||
'- Last 5 plans: none',
|
||
'- Trend: Stable',
|
||
'',
|
||
'*Updated after each plan completion*',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), byPhaseFixture);
|
||
|
||
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
assert.ok(!output.created, 'created must be absent/false — the section already existed');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
// (a) By-Phase table unchanged — header + placeholder row intact, and no
|
||
// metric row spliced in between the delimiter and the next blank line.
|
||
const byPhaseIdx = updated.indexOf('| Phase | Plans | Total | Avg/Plan |');
|
||
assert.ok(byPhaseIdx !== -1, 'By-Phase table header must still exist');
|
||
const afterHeader = updated.slice(byPhaseIdx);
|
||
const byPhaseBlock = afterHeader.slice(0, afterHeader.indexOf('\n\n'));
|
||
assert.ok(byPhaseBlock.includes('|-------|-------|-------|----------|'), 'By-Phase delimiter must be intact');
|
||
assert.ok(byPhaseBlock.includes('| - | - | - | - |'), 'By-Phase placeholder row must be intact');
|
||
assert.ok(!byPhaseBlock.includes('Phase 1 P1'), 'the per-plan row must NOT be spliced into the By-Phase table block');
|
||
assert.ok(!byPhaseBlock.includes('5min'), 'the per-plan duration must NOT appear in the By-Phase table block');
|
||
|
||
// (b) A dedicated `| Plan | Duration | Tasks | Files |` table now exists,
|
||
// containing the new row.
|
||
const metricsIdx = updated.indexOf('| Plan | Duration | Tasks | Files |');
|
||
assert.ok(metricsIdx !== -1, 'a Per-Plan Metrics table must now exist');
|
||
assert.ok(updated.includes('| Phase 1 P1 | 5min | 3 tasks | 4 files |'), 'the new metric row must be present in the Per-Plan Metrics table');
|
||
|
||
// (c) exactly ONE "## Performance Metrics" heading.
|
||
const headingMatches = updated.match(/^## Performance Metrics\s*$/gim) || [];
|
||
assert.strictEqual(headingMatches.length, 1, `exactly ONE "## Performance Metrics" heading expected, got ${headingMatches.length}:\n${updated}`);
|
||
|
||
// (d) Recent Trend block + footer preserved.
|
||
assert.ok(updated.includes('**Recent Trend:**'), 'Recent Trend block must be preserved');
|
||
assert.ok(updated.includes('*Updated after each plan completion*'), 'footer must be preserved');
|
||
});
|
||
|
||
test('replaces None yet placeholder with first metric', () => {
|
||
const noneYetFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Performance Metrics',
|
||
'',
|
||
'| Plan | Duration | Tasks | Files |',
|
||
'|------|----------|-------|-------|',
|
||
'None yet',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), noneYetFixture);
|
||
|
||
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 2min --tasks 1 --files 2', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(!updated.includes('None yet'), 'None yet placeholder should be removed');
|
||
assert.ok(updated.includes('| Phase 1 P1 | 2min | 1 tasks | 2 files |'), 'new row should be present');
|
||
});
|
||
|
||
test('returns error when required fields missing', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), metricsFixture);
|
||
|
||
const result = runGsdTools('state record-metric --phase 1', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(
|
||
output.error.includes('phase') || output.error.includes('plan') || output.error.includes('duration'),
|
||
'error should mention missing required fields'
|
||
);
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 2min', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||
});
|
||
});
|
||
|
||
describe('cmdStateUpdateProgress (state update-progress)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('calculates progress from plan/summary counts', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Progress:** [░░░░░░░░░░] 0%\n'
|
||
);
|
||
|
||
// Phase 01: 1 PLAN + 1 SUMMARY = completed
|
||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01');
|
||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
// Phase 02: 1 PLAN only = not completed
|
||
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02');
|
||
fs.mkdirSync(phase02Dir, { recursive: true });
|
||
fs.writeFileSync(path.join(phase02Dir, '02-01-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.updated, true, 'updated should be true');
|
||
assert.strictEqual(output.percent, 50, 'percent should be 50');
|
||
assert.strictEqual(output.completed, 1, 'completed should be 1');
|
||
assert.strictEqual(output.total, 2, 'total should be 2');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('50%'), 'STATE.md Progress should contain 50%');
|
||
});
|
||
|
||
test('handles zero plans gracefully', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Progress:** [░░░░░░░░░░] 0%\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.percent, 0, 'percent should be 0 when no plans found');
|
||
});
|
||
|
||
test('returns error when Progress field missing', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.updated, false, 'updated should be false');
|
||
assert.ok(output.reason !== undefined, 'should have a reason');
|
||
});
|
||
|
||
// ── #2177: frontmatter `progress:` key must not shadow the body Progress: line ──
|
||
|
||
test('#2177 frontmatter progress: key is not matched — body Progress: line is the target', () => {
|
||
// A STATE.md carrying YAML frontmatter (which writeStateMd adds to every
|
||
// STATE.md). The lowercase `progress:` key used to be matched first by the
|
||
// case-insensitive pattern, mangling the frontmatter and leaving the body
|
||
// line stale.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
[
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: executing',
|
||
'progress:',
|
||
' total_phases: 1',
|
||
' completed_phases: 0',
|
||
' total_plans: 2',
|
||
' completed_plans: 1',
|
||
' percent: 20',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'Progress: [██░░░░░░░░] 20% (1/2 plans complete)',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
// 1 of 2 plans complete → 50%.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
assert.strictEqual(out.updated, true);
|
||
assert.strictEqual(out.percent, 50);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
// The body line advanced to 50% AND its descriptive suffix survived.
|
||
assert.ok(updated.includes('[█████░░░░░] 50% (1/2 plans complete)'),
|
||
'body Progress line must update to 50% with suffix preserved');
|
||
// The frontmatter block is intact (not mangled by the old \s*-crosses-newline match).
|
||
assert.ok(updated.includes('total_phases: 1'), 'frontmatter total_phases key must survive');
|
||
assert.ok(updated.includes('percent:'), 'frontmatter percent key must survive');
|
||
});
|
||
|
||
test('#2177 descriptive suffix after the machine segment is preserved', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Progress:** [█████░░░░░] 50% (2/4 plans done; blocked on API keys)\n'
|
||
);
|
||
// 1 of 1 plan complete → 100%.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success);
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/\[██████████\] 100% \(2\/4 plans done; blocked on API keys\)/.test(updated),
|
||
'the machine segment updates to 100% while the suffix is preserved verbatim');
|
||
});
|
||
|
||
test('#2177 no body Progress: line → updated:false even if frontmatter has a progress: key', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
['---', 'progress:', ' percent: 0', '---', '', '# Project State', '', '**Status:** Active', ''].join('\n')
|
||
);
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success);
|
||
const out = JSON.parse(result.output);
|
||
assert.strictEqual(out.updated, false,
|
||
'a frontmatter progress: key with no body Progress: line must not report a false success');
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state update-progress', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdStateResolveBlocker, cmdStateRecordSession
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('cmdStateResolveBlocker (state resolve-blocker)', () => {
|
||
let tmpDir;
|
||
|
||
const blockerFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Blockers',
|
||
'',
|
||
'- Waiting for API credentials',
|
||
'- Need design review for dashboard',
|
||
'- Pending vendor approval',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('removes matching blocker line (case-insensitive substring match)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
|
||
|
||
const result = runGsdTools('state resolve-blocker --text "api credentials"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.resolved, true, 'resolved should be true');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(!updated.includes('Waiting for API credentials'), 'matched blocker should be removed');
|
||
assert.ok(updated.includes('Need design review for dashboard'), 'other blocker should still be present');
|
||
assert.ok(updated.includes('Pending vendor approval'), 'other blocker should still be present');
|
||
});
|
||
|
||
test('adds None placeholder when last blocker resolved', () => {
|
||
const singleBlockerFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Blockers',
|
||
'',
|
||
'- Single blocker',
|
||
'',
|
||
'## Session Continuity',
|
||
].join('\n') + '\n';
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), singleBlockerFixture);
|
||
|
||
const result = runGsdTools('state resolve-blocker --text "single blocker"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(!updated.includes('- Single blocker'), 'resolved blocker should be removed');
|
||
|
||
// Section should contain "None" placeholder, not be empty
|
||
const sectionMatch = updated.match(/## Blockers\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(sectionMatch, 'Blockers section should still exist');
|
||
assert.ok(sectionMatch[1].includes('None'), 'Blockers section should contain None placeholder');
|
||
});
|
||
|
||
test('returns error when text not provided', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
|
||
|
||
const result = runGsdTools('state resolve-blocker', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(
|
||
output.error.toLowerCase().includes('text'),
|
||
'error should mention text required'
|
||
);
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state resolve-blocker --text "anything"', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||
});
|
||
|
||
test('returns resolved true even if no line matches', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
|
||
|
||
const result = runGsdTools('state resolve-blocker --text "nonexistent blocker text"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.resolved, true, 'resolved should be true even when no line matches');
|
||
});
|
||
});
|
||
|
||
describe('cmdStateRecordSession (state record-session)', () => {
|
||
let tmpDir;
|
||
|
||
const sessionFixture = [
|
||
'# Project State',
|
||
'',
|
||
'## Session Continuity',
|
||
'',
|
||
'**Last session:** 2024-01-10',
|
||
'**Stopped at:** Phase 2, Plan 1',
|
||
'**Resume file:** None',
|
||
].join('\n') + '\n';
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('updates session fields with stopped-at and resume-file', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||
|
||
const PINNED_MS = Date.parse('2020-07-20T10:00:00.000Z');
|
||
const PINNED_ISO = '2020-07-20T10:00:00.000Z';
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 3, Plan 2" --resume-file ".planning/phases/03/03-02-PLAN.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
assert.ok(Array.isArray(output.updated), 'updated should be an array');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('Phase 3, Plan 2'), 'Stopped at should be updated');
|
||
assert.ok(updated.includes('.planning/phases/03/03-02-PLAN.md'), 'Resume file should be updated');
|
||
assert.ok(updated.includes(PINNED_ISO), `Last session should be the pinned ISO timestamp ${PINNED_ISO}`);
|
||
});
|
||
|
||
test('updates Last session timestamp even with no other options', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||
|
||
const PINNED_MS = Date.parse('2020-08-01T08:30:00.000Z');
|
||
const PINNED_ISO = '2020-08-01T08:30:00.000Z';
|
||
const result = runGsdTools('state record-session', tmpDir, {
|
||
GSD_TEST_MODE: '1',
|
||
GSD_NOW_MS: String(PINNED_MS),
|
||
});
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes(PINNED_ISO), `Last session should contain the pinned ISO timestamp ${PINNED_ISO}`);
|
||
});
|
||
|
||
test('sets Resume file to None when not specified', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||
|
||
const result = runGsdTools('state record-session --stopped-at "Phase 1 complete"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(updated.includes('Phase 1 complete'), 'Stopped at should be updated');
|
||
// Resume file should be set to None (default)
|
||
const resumeMatch = updated.match(/\*\*Resume file:\*\*\s*(.*)/i);
|
||
assert.ok(resumeMatch, 'Resume file field should exist');
|
||
assert.ok(resumeMatch[1].trim() === 'None', 'Resume file should be None when not specified');
|
||
});
|
||
|
||
test('returns error when STATE.md missing', () => {
|
||
const result = runGsdTools('state record-session', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error !== undefined, 'output should have error field');
|
||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||
});
|
||
|
||
test('returns recorded false when no session fields found', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Status:** Active\n**Phase:** 03\n'
|
||
);
|
||
|
||
const result = runGsdTools('state record-session', tmpDir);
|
||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, false, 'recorded should be false when no session fields found');
|
||
assert.ok(output.reason !== undefined, 'should have a reason');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Milestone-scoped phase counting in frontmatter
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('milestone-scoped phase counting in frontmatter', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('total_phases counts only current milestone phases', () => {
|
||
// ROADMAP lists only phases 5-6 (current milestone)
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'## Roadmap v2.0: Next Release',
|
||
'',
|
||
'### Phase 5: Auth',
|
||
'**Goal:** Add authentication',
|
||
'',
|
||
'### Phase 6: Dashboard',
|
||
'**Goal:** Build dashboard',
|
||
].join('\n')
|
||
);
|
||
|
||
// Disk has dirs 01-06 (01-04 are leftover from previous milestone)
|
||
for (let i = 1; i <= 6; i++) {
|
||
const padded = String(i).padStart(2, '0');
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
// Add a plan to each
|
||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
|
||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
|
||
}
|
||
|
||
// Write a STATE.md and trigger a write that will sync frontmatter
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Current Phase:** 05\n**Status:** In progress\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update Status "Executing"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
// Read the state json to check frontmatter
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const output = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(Number(output.progress.total_phases), 2, 'should count only milestone phases (5 and 6), not all 6');
|
||
assert.strictEqual(Number(output.progress.completed_phases), 2, 'both milestone phases have summaries');
|
||
});
|
||
|
||
test('total_phases includes ROADMAP phases without directories', () => {
|
||
// ROADMAP lists 6 phases (5-10), but only 4 have directories on disk
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'## Roadmap v3.0',
|
||
'',
|
||
'### Phase 5: Auth',
|
||
'### Phase 6: Dashboard',
|
||
'### Phase 7: API',
|
||
'### Phase 8: Notifications',
|
||
'### Phase 9: Analytics',
|
||
'### Phase 10: Polish',
|
||
].join('\n')
|
||
);
|
||
|
||
// Only phases 5-8 have directories (9 and 10 not yet planned)
|
||
for (let i = 5; i <= 8; i++) {
|
||
const padded = String(i).padStart(2, '0');
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
|
||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
|
||
}
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Current Phase:** 08\n**Status:** In progress\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update Status "Executing"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const output = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(Number(output.progress.total_phases), 6, 'should count all 6 ROADMAP phases, not just 4 with directories');
|
||
assert.strictEqual(Number(output.progress.completed_phases), 4, 'only 4 phases have summaries');
|
||
});
|
||
|
||
test('without ROADMAP counts all phases (pass-all filter)', () => {
|
||
// No ROADMAP.md — all phases should be counted
|
||
for (let i = 1; i <= 4; i++) {
|
||
const padded = String(i).padStart(2, '0');
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
|
||
}
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update Status "In progress"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const output = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(Number(output.progress.total_phases), 4, 'without ROADMAP should count all 4 phases');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// begin-phase — field preservation (#1365)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('state begin-phase preserves Current Position fields (#1365)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('begin-phase preserves Status, Last activity, and Progress in Current Position', () => {
|
||
const stateMd = `# Project State
|
||
|
||
**Current Phase:** 1
|
||
**Current Phase Name:** setup
|
||
**Total Phases:** 5
|
||
**Current Plan:** 0
|
||
**Total Plans in Phase:** 0
|
||
**Status:** Ready to plan
|
||
**Last Activity:** 2026-03-20
|
||
**Last Activity Description:** Roadmap created
|
||
|
||
## Current Position
|
||
Phase: 1 of 5 (setup)
|
||
Plan: 0 of ? in current phase
|
||
Status: Ready to plan
|
||
Last activity: 2026-03-20 -- Roadmap created
|
||
Progress: [..........] 0%
|
||
|
||
## Decisions Made
|
||
|
||
| Phase | Decision | Rationale |
|
||
|-------|----------|-----------|
|
||
`;
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'
|
||
);
|
||
|
||
// Extract the Current Position section
|
||
const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(posMatch, 'Current Position section should exist');
|
||
const posSection = posMatch[1];
|
||
|
||
// Phase and Plan lines should be updated
|
||
assert.ok(/^Phase:.*EXECUTING/m.test(posSection), 'Phase line should say EXECUTING');
|
||
assert.ok(/^Plan:.*1 of 4/m.test(posSection), 'Plan line should show 1 of 4');
|
||
|
||
// Status, Last activity, and Progress must still be present (the bug destroys these)
|
||
assert.ok(/^Status:/m.test(posSection),
|
||
'Status field must be preserved in Current Position');
|
||
assert.ok(/^Last activity:/m.test(posSection),
|
||
'Last activity field must be preserved in Current Position');
|
||
assert.ok(/^Progress:/m.test(posSection),
|
||
'Progress field must be preserved in Current Position');
|
||
});
|
||
|
||
test('#2245 F2: begin-phase does not clobber an H3 subsection nested under Current Position', () => {
|
||
// A prior revision swapped `locateCurrentPosition` (stops at ANY heading
|
||
// level >= 2) for `collectSection` with its default `levelBounded: true`
|
||
// (stops only at H1/H2), so a `### Notes` subsection under
|
||
// `## Current Position` was folded into the section body and the
|
||
// field-write regexes (which use the `m` flag and match ANY line start)
|
||
// clobbered a same-named line inside that subsection.
|
||
const stateMd = `# Project State
|
||
|
||
## Current Position
|
||
|
||
Phase: 1 (Setup) — EXECUTING
|
||
Status: Executing Phase 1
|
||
|
||
### Notes
|
||
|
||
Plan: DO-NOT-TOUCH
|
||
|
||
## Next Steps
|
||
|
||
Do the thing.
|
||
`;
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'begin-phase', '--phase', '2', '--name', 'Foo', '--plans', '3'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `begin-phase failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(
|
||
content.includes('Plan: DO-NOT-TOUCH'),
|
||
`the ### Notes subsection must not be rewritten by the Current Position field regexes:\n${content}`,
|
||
);
|
||
assert.ok(/^## Next Steps/m.test(content), 'the ## Next Steps section must remain untouched');
|
||
assert.ok(/^Phase:.*EXECUTING/m.test(content), 'Phase line in Current Position should still update');
|
||
});
|
||
|
||
test('advance-plan can update Status after begin-phase', () => {
|
||
// Simulates the full workflow: begin-phase then advance through all plans
|
||
const stateMd = `# Project State
|
||
|
||
**Current Phase:** 1
|
||
**Current Phase Name:** setup
|
||
**Total Phases:** 5
|
||
**Current Plan:** 0
|
||
**Total Plans in Phase:** 0
|
||
**Status:** Ready to plan
|
||
**Last Activity:** 2026-03-20
|
||
**Last Activity Description:** Roadmap created
|
||
|
||
## Current Position
|
||
Phase: 1 of 5 (setup)
|
||
Plan: 0 of ? in current phase
|
||
Status: Ready to plan
|
||
Last activity: 2026-03-20 -- Roadmap created
|
||
Progress: [..........] 0%
|
||
|
||
## Decisions Made
|
||
|
||
| Phase | Decision | Rationale |
|
||
|-------|----------|-----------|
|
||
`;
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
// Step 1: begin-phase
|
||
const beginResult = runGsdTools(
|
||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '2'],
|
||
tmpDir
|
||
);
|
||
assert.ok(beginResult.success, `begin-phase failed: ${beginResult.error}`);
|
||
|
||
// Step 2: advance-plan to go from plan 1 to plan 2
|
||
const adv1 = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||
assert.ok(adv1.success, `advance-plan 1 failed: ${adv1.error}`);
|
||
|
||
// Step 3: advance-plan again — plan 2 of 2 is the last, should set "Phase complete"
|
||
const adv2 = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||
assert.ok(adv2.success, `advance-plan 2 failed: ${adv2.error}`);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'
|
||
);
|
||
const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(posMatch, 'Current Position section should exist after advance-plan');
|
||
const posSection = posMatch[1];
|
||
|
||
// After advancing past all plans, Status should say "Phase complete"
|
||
assert.ok(/Status:.*Phase complete/i.test(posSection),
|
||
'Status should be updated to "Phase complete" after last advance-plan');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #1589 — progress counters not updated during plan execution
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('progress counters correct after plan execution (#1589)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('percent in frontmatter is derived from disk counts, not stale Progress body field', () => {
|
||
// STATE.md body still says 0% (update-progress was never called or was skipped),
|
||
// but all 4 plans across 2 phases have SUMMARY.md files on disk.
|
||
// After any STATE.md write, the frontmatter percent must reflect disk reality.
|
||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||
fs.mkdirSync(phase02Dir, { recursive: true });
|
||
|
||
// Phase 01: 2 plans, 2 summaries (complete)
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase01Dir, '01-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase01Dir, '01-02-SUMMARY.md'), '# Summary\n');
|
||
|
||
// Phase 02: 2 plans, 2 summaries (complete)
|
||
fs.writeFileSync(path.join(phase02Dir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-02-SUMMARY.md'), '# Summary\n');
|
||
|
||
// Body Progress: still says 0% (stale — never updated by update-progress)
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Current Phase:** 02\n**Status:** Phase complete — ready for verification\n**Progress:** [░░░░░░░░░░] 0%\n'
|
||
);
|
||
|
||
// Trigger a STATE.md write (e.g. state update Status)
|
||
const result = runGsdTools('state update Status "All phases complete"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
// Read the frontmatter — percent must be derived from disk (4/4 = 100%), not from body "0%"
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const output = JSON.parse(jsonResult.output);
|
||
|
||
assert.ok(output.progress, 'frontmatter must have progress object');
|
||
assert.strictEqual(Number(output.progress.total_plans), 4, 'total_plans must be 4 from disk');
|
||
assert.strictEqual(Number(output.progress.completed_plans), 4, 'completed_plans must be 4 from disk');
|
||
assert.strictEqual(Number(output.progress.total_phases), 2, 'total_phases must be 2 from disk');
|
||
assert.strictEqual(Number(output.progress.completed_phases), 2, 'completed_phases must be 2 from disk');
|
||
assert.strictEqual(Number(output.progress.percent), 100, 'percent must be 100 (derived from disk counts, not stale body 0%)');
|
||
});
|
||
|
||
test('percent is 0 when no summaries exist even if Progress body says 100%', () => {
|
||
// Inverse: body says 100% but disk has no summaries.
|
||
// Frontmatter percent must come from disk, not body.
|
||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan\n');
|
||
// No summary files
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Project State\n\n**Current Phase:** 01\n**Status:** In progress\n**Progress:** [██████████] 100%\n'
|
||
);
|
||
|
||
const result = runGsdTools('state update Status "Executing"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const output = JSON.parse(jsonResult.output);
|
||
|
||
assert.ok(output.progress, 'frontmatter must have progress object');
|
||
assert.strictEqual(Number(output.progress.total_plans), 1, 'total_plans must be 1 from disk');
|
||
assert.strictEqual(Number(output.progress.completed_plans), 0, 'completed_plans must be 0 (no summaries)');
|
||
assert.strictEqual(Number(output.progress.percent), 0, 'percent must be 0 (derived from disk, not stale body 100%)');
|
||
});
|
||
|
||
test('state json rebuilds stale frontmatter progress from disk after all plans complete', () => {
|
||
// Reproduces the exact scenario from #1589:
|
||
// Frontmatter was written early with stale counters.
|
||
// All summaries now exist on disk.
|
||
// state json must return fresh disk-derived progress.
|
||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-phase');
|
||
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-phase');
|
||
const phase03Dir = path.join(tmpDir, '.planning', 'phases', '03-phase');
|
||
const phase04Dir = path.join(tmpDir, '.planning', 'phases', '04-phase');
|
||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||
fs.mkdirSync(phase02Dir, { recursive: true });
|
||
fs.mkdirSync(phase03Dir, { recursive: true });
|
||
fs.mkdirSync(phase04Dir, { recursive: true });
|
||
|
||
// 4 phases, 6 total plans (as in the bug report)
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase02Dir, '02-02-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase03Dir, '03-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase03Dir, '03-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase04Dir, '04-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase04Dir, '04-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phase04Dir, '04-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase04Dir, '04-02-SUMMARY.md'), '# Summary\n');
|
||
|
||
// Write STATE.md with stale frontmatter matching the bug report exactly
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---\ngsd_state_version: '1.0'\nstatus: executing\nprogress:\n total_phases: 4\n completed_phases: 0\n total_plans: 0\n completed_plans: 4\n percent: 0\n---\n\n# Project State\n\n**Current Phase:** 04\n**Status:** Ready to execute\n**Progress:** [░░░░░░░░░░] 0%\n`
|
||
);
|
||
|
||
// state json must return fresh progress derived from disk (all 6 plans complete across 4 phases)
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const output = JSON.parse(jsonResult.output);
|
||
|
||
assert.ok(output.progress, 'frontmatter must have progress object');
|
||
assert.strictEqual(Number(output.progress.total_plans), 6, 'total_plans must be 6 (not stale 0)');
|
||
assert.strictEqual(Number(output.progress.completed_plans), 6, 'completed_plans must be 6 (not stale 4)');
|
||
assert.strictEqual(Number(output.progress.total_phases), 4, 'total_phases must be 4');
|
||
assert.strictEqual(Number(output.progress.completed_phases), 4, 'completed_phases must be 4 (not stale 0)');
|
||
assert.strictEqual(Number(output.progress.percent), 100, 'percent must be 100 (not stale 0)');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// updatePerformanceMetricsSection (Step 1)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('updatePerformanceMetricsSection', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('empty Performance Metrics section rebuilds with zeros', () => {
|
||
const content = `# Project State
|
||
|
||
**Status:** Executing Phase 3
|
||
|
||
## Performance Metrics
|
||
|
||
**Velocity:**
|
||
- Total plans completed: [N]
|
||
- Average duration: [X] min
|
||
- Total execution time: [X.X] hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
| - | - | - | - |
|
||
|
||
## Accumulated Context
|
||
`;
|
||
|
||
// We test via the CLI: phase complete triggers updatePerformanceMetricsSection
|
||
// But first let's test the helper directly via state planned-phase + phase complete flow
|
||
// For a unit-style test, write STATE.md and call state validate to check metrics
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// Create a phase with 2 plans, 2 summaries
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan 1\n');
|
||
fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), '# Plan 2\n');
|
||
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary 1\n');
|
||
fs.writeFileSync(path.join(phaseDir, '03-02-SUMMARY.md'), '# Summary 2\n');
|
||
writePassedVerification(tmpDir, '03-api', '03');
|
||
|
||
// Also need ROADMAP.md for phase complete
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 3: API\n\n- [ ] Phase 3: API Layer\n`
|
||
);
|
||
|
||
const result = runGsdTools('phase complete 3', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const stateAfter = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(stateAfter.includes('Total plans completed:'), 'Velocity section should have total plans');
|
||
assert.ok(stateAfter.match(/Total plans completed:\s*2/), 'Total plans should be 2');
|
||
assert.ok(stateAfter.includes('| 3'), 'By Phase table should have row for phase 3');
|
||
});
|
||
|
||
test('existing Plan Execution Times rows aggregated into Velocity/By Phase', () => {
|
||
const content = `# Project State
|
||
|
||
**Current Phase:** 04
|
||
**Status:** Executing Phase 4
|
||
|
||
## Performance Metrics
|
||
|
||
| Plan | Duration | Tasks | Files |
|
||
|------|----------|-------|-------|
|
||
| Phase 3 P1 | 12 min | 5 tasks | 3 files |
|
||
| Phase 3 P2 | 8 min | 3 tasks | 2 files |
|
||
|
||
**Velocity:**
|
||
- Total plans completed: 2
|
||
- Average duration: 10 min
|
||
- Total execution time: 0.3 hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
| 3 | 2 | 20 min | 10 min |
|
||
|
||
## Accumulated Context
|
||
`;
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// Create phase 4 with 1 plan, 1 summary
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '04-01-PLAN.md'), '# Plan 1\n');
|
||
fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'), '# Summary 1\n');
|
||
writePassedVerification(tmpDir, '04-ui', '04');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 4: UI\n\n- [ ] Phase 4: UI Layer\n`
|
||
);
|
||
|
||
const result = runGsdTools('phase complete 4', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const stateAfter = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans should be 3 (2 previous + 1 new)');
|
||
assert.ok(stateAfter.includes('| 4'), 'By Phase table should have row for phase 4');
|
||
});
|
||
|
||
test('idempotent — running twice produces same result', () => {
|
||
const content = `# Project State
|
||
|
||
**Current Phase:** 05
|
||
**Status:** Executing Phase 5
|
||
|
||
## Performance Metrics
|
||
|
||
**Velocity:**
|
||
- Total plans completed: 0
|
||
- Average duration: N/A
|
||
- Total execution time: 0 hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
|
||
## Accumulated Context
|
||
`;
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-final');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '05-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '05-01-SUMMARY.md'), '# Summary\n');
|
||
writePassedVerification(tmpDir, '05-final', '05');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 5: Final\n\n- [ ] Phase 5: Final\n`
|
||
);
|
||
|
||
runGsdTools('phase complete 5', tmpDir);
|
||
const afterFirst = fs.readFileSync(statePath, 'utf-8');
|
||
|
||
// Reset state so we can complete again
|
||
let resetContent = afterFirst.replace(/All phases complete|Ready to plan/, 'Executing Phase 5');
|
||
resetContent = resetContent.replace(/Not started/, '1');
|
||
fs.writeFileSync(statePath, resetContent);
|
||
|
||
// Re-create plan files (they still exist)
|
||
runGsdTools('phase complete 5', tmpDir);
|
||
const afterSecond = fs.readFileSync(statePath, 'utf-8');
|
||
|
||
// #1582: the velocity total must be IDEMPOTENT across re-runs of the same phase.
|
||
// The old blind-add (prevTotal + summaryCount) double-counted on every re-run
|
||
// (1 -> 2 here); the fix derives the total from the By-Phase Plans column, so
|
||
// re-running the same phase upserts the same row and the sum stays stable.
|
||
const firstCount = afterFirst.match(/Total plans completed:\s*(\d+)/);
|
||
const secondCount = afterSecond.match(/Total plans completed:\s*(\d+)/);
|
||
assert.ok(firstCount, 'First run should have total plans');
|
||
assert.ok(secondCount, 'Second run should have total plans');
|
||
assert.equal(
|
||
firstCount[1],
|
||
secondCount[1],
|
||
`velocity total must be idempotent across re-runs of phase 5 (#1582): first=${firstCount[1]} second=${secondCount[1]}`,
|
||
);
|
||
assert.equal(firstCount[1], '1', 'phase 5 has 1 plan, so the velocity total must be 1');
|
||
// The By Phase row for phase 5 should be updated, not duplicated.
|
||
const phase5Rows = (afterSecond.match(/\|\s*5\s*\|/g) || []).length;
|
||
assert.ok(phase5Rows <= 1, 'Phase 5 should appear at most once in By Phase table (no duplicates)');
|
||
});
|
||
|
||
test('#1582 — velocity self-heals a hand-inflated total down to the true By-Phase sum', () => {
|
||
// A hand-edited STATE.md whose velocity line says 99 but whose By-Phase table
|
||
// records the true completed plans. Completing a fresh phase must RECOMPUTE the
|
||
// total from the table (derive, not accumulate), correcting the inflated value
|
||
// downward rather than adding to it.
|
||
const content = `# Project State
|
||
|
||
**Current Phase:** 02
|
||
**Status:** Executing Phase 2
|
||
|
||
## Performance Metrics
|
||
|
||
**Velocity:**
|
||
- Total plans completed: 99
|
||
- Average duration: 5 min
|
||
- Total execution time: 0.1 hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
| 1 | 2 | 10 min | 5 min |
|
||
|
||
## Accumulated Context
|
||
`;
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-next');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
writePassedVerification(tmpDir, '02-next', '02');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 2: Next\n\n- [ ] Phase 2: Next\n`
|
||
);
|
||
|
||
const result = runGsdTools('phase complete 2', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const stateAfter = fs.readFileSync(statePath, 'utf-8');
|
||
// True sum = phase 1 (2) + phase 2 (1) = 3. Old blind-add would yield 99 + 1 = 100.
|
||
assert.ok(
|
||
stateAfter.match(/Total plans completed:\s*3\b/),
|
||
'velocity total must self-heal to the true By-Phase sum (3), not accumulate from the inflated 99 (#1582)',
|
||
);
|
||
});
|
||
|
||
test('#1582 — velocity sums indented By-Phase data rows too (codex review: byPhaseTablePattern allows [ \\t]* leading whitespace, so the sum must match it)', () => {
|
||
// byPhaseTablePattern's data-row capture is `(?:[ \\t]*\\|...)*` — it ALLOWS leading
|
||
// whitespace. The derive sum must tolerate the same, or a hand-edited/legacy indented
|
||
// row is captured by the table but silently skipped by the sum (undercount).
|
||
const content = `# Project State
|
||
|
||
**Current Phase:** 02
|
||
**Status:** Executing Phase 2
|
||
|
||
## Performance Metrics
|
||
|
||
**Velocity:**
|
||
- Total plans completed: 0
|
||
- Average duration: N/A
|
||
- Total execution time: 0 hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
| 1 | 2 | - | - |
|
||
|
||
## Accumulated Context
|
||
`;
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-next');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
writePassedVerification(tmpDir, '02-next', '02');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 2: Next\n\n- [ ] Phase 2: Next\n`
|
||
);
|
||
|
||
const result = runGsdTools('phase complete 2', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const stateAfter = fs.readFileSync(statePath, 'utf-8');
|
||
// Indented phase-1 row (2) + new column-0 phase-2 row (1) = 3. A sum regex anchored
|
||
// at ^\\| would skip the indented row and report 1.
|
||
assert.ok(
|
||
stateAfter.match(/Total plans completed:\s*3\b/),
|
||
'velocity must sum indented By-Phase rows too (codex review, #1582): expected 3 (2 + 1)',
|
||
);
|
||
});
|
||
|
||
test('byPhaseTablePattern behavior-lock (#320): By Phase table header preserved and phase row upserted after hoist to module scope', () => {
|
||
// Exercises the byPhaseTablePattern match path directly: header must be preserved,
|
||
// an existing phase row must be replaced (not duplicated), and a new phase row inserted.
|
||
const content = `# Project State
|
||
|
||
**Current Phase:** 06
|
||
**Status:** Executing Phase 6
|
||
|
||
## Performance Metrics
|
||
|
||
**Velocity:**
|
||
- Total plans completed: 1
|
||
- Average duration: 5 min
|
||
- Total execution time: 0.1 hours
|
||
|
||
**By Phase:**
|
||
|
||
| Phase | Plans | Total | Avg/Plan |
|
||
|-------|-------|-------|----------|
|
||
| 6 | 1 | 5 min | 5 min |
|
||
|
||
## Accumulated Context
|
||
`;
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '06-lock');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '06-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '06-02-PLAN.md'), '# Plan 2\n');
|
||
fs.writeFileSync(path.join(phaseDir, '06-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phaseDir, '06-02-SUMMARY.md'), '# Summary 2\n');
|
||
writePassedVerification(tmpDir, '06-lock', '06');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n## Phase 6: Lock\n\n- [ ] Phase 6: Lock\n`
|
||
);
|
||
|
||
const result = runGsdTools('phase complete 6', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const stateAfter = fs.readFileSync(statePath, 'utf-8');
|
||
|
||
// Header must be preserved
|
||
assert.ok(stateAfter.includes('| Phase | Plans | Total | Avg/Plan |'), 'By Phase table header must be preserved');
|
||
|
||
// Phase 6 row must appear exactly once (upserted, not duplicated)
|
||
const phase6Rows = (stateAfter.match(/\|\s*6\s*\|/g) || []).length;
|
||
assert.strictEqual(phase6Rows, 1, 'Phase 6 row must appear exactly once in By Phase table (upsert, not append)');
|
||
|
||
// Total plans count = sum of the By-Phase Plans column after the upsert. Phase 6's
|
||
// row is upserted to its current summaryCount (2), and it is the only row, so the
|
||
// derived total is 2. (#1582: derived from the table, not blind-added onto the prior
|
||
// velocity — which previously produced 1+2=3 by double-counting phase 6.)
|
||
assert.ok(stateAfter.match(/Total plans completed:\s*2\b/), 'Total plans completed should equal the By-Phase Plans sum (2) after upsert (#1582)');
|
||
});
|
||
|
||
test('#1658 — By-Phase table row upserts on a CRLF STATE.md (byPhaseTablePattern must be CRLF-tolerant)', () => {
|
||
const content = [
|
||
'# Project State', '',
|
||
'**Current Phase:** 07', '**Status:** Executing Phase 7', '',
|
||
'## Performance Metrics', '',
|
||
'**Velocity:**',
|
||
'- Total plans completed: [N]',
|
||
'- Average duration: N/A',
|
||
'- Total execution time: 0 hours', '',
|
||
'**By Phase:**', '',
|
||
'| Phase | Plans | Total | Avg/Plan |',
|
||
'|-------|-------|-------|----------|',
|
||
'| - | - | - | - |', '',
|
||
'## Accumulated Context', '',
|
||
].join('\n');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Force CRLF line endings across the whole STATE.md (Windows / hand-edited).
|
||
fs.writeFileSync(statePath, content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-crlf');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '07-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '07-01-SUMMARY.md'), '# Summary\n');
|
||
// #1548 (#1522) enforces canonical verification before phase transition, so phase
|
||
// complete fail-closes without a passed VERIFICATION.md. Add one so the test exercises
|
||
// the By-Phase row upsert path (the actual #1658 concern) rather than the gate.
|
||
writePassedVerification(tmpDir, '07-crlf', '07');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 7: CRLF\n\n- [ ] Phase 7\n');
|
||
|
||
const result = runGsdTools('phase complete 7', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf8');
|
||
// #1658: byPhaseTablePattern is CRLF-tolerant. #1668 (By-Phase row not persisted on a
|
||
// CRLF STATE.md even though the pattern matches CRLF) was resolved by #1655's
|
||
// restructure of updatePerformanceMetricsSection (table upsert now runs before the
|
||
// velocity manipulation). Assert the full contract: row present, placeholder removed,
|
||
// velocity derived — all on a CRLF STATE.md.
|
||
assert.ok(
|
||
/\|\s*7\s*\|\s*1\s*\|/.test(after),
|
||
'By-Phase row for phase 7 must be upserted even on a CRLF STATE.md (#1658/#1668)',
|
||
);
|
||
assert.ok(
|
||
!/\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|/.test(after),
|
||
'placeholder row must be removed on CRLF STATE.md once a real row is upserted',
|
||
);
|
||
assert.ok(
|
||
/Total plans completed:\s*1\b/.test(after),
|
||
'velocity total must derive from the CRLF By-Phase table (1 plan)',
|
||
);
|
||
});
|
||
|
||
test('#1659 — completing an unpadded phase number upserts an existing zero-padded By-Phase row (no duplicate)', () => {
|
||
const content = [
|
||
'# Project State', '',
|
||
'**Current Phase:** 05', '**Status:** Executing Phase 5', '',
|
||
'## Performance Metrics', '',
|
||
'**Velocity:**', '- Total plans completed: 1', '- Average duration: N/A', '- Total execution time: 0 hours', '',
|
||
'**By Phase:**', '',
|
||
'| Phase | Plans | Total | Avg/Plan |',
|
||
'|-------|-------|-------|----------|',
|
||
'| 05 | 1 | - | - |', // seeded ZERO-PADDED row
|
||
'',
|
||
'## Accumulated Context', '',
|
||
].join('\n');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, content, 'utf8');
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-final');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '05-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '05-01-SUMMARY.md'), '# Summary\n');
|
||
writePassedVerification(tmpDir, '05-final', '05');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 5: Final\n\n- [ ] Phase 5\n');
|
||
|
||
// phase complete with the UNPADDED number "5" — must upsert the seeded "| 05 |" row,
|
||
// not append a duplicate "| 5 |".
|
||
const result = runGsdTools('phase complete 5', tmpDir);
|
||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf8');
|
||
const rows05 = (after.match(/^\|\s*05\s*\|/gm) || []).length;
|
||
const rows5 = (after.match(/^\|\s*5\s*\|/gm) || []).length;
|
||
assert.equal(rows05 + rows5, 1, `phase 5 must appear exactly once in By Phase (got |05|=${rows05} |5|=${rows5}) — padded/unpadded must dedup (#1659)`);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state planned-phase (Step 3 — Gate 3a)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('state planned-phase command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('after call: Status is "Ready to execute"', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Planning Phase 3\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 3\n`
|
||
);
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '--phase', '3', '--name', 'API', '--plans', '5'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(stateContent.includes('Ready to execute'), 'Status should be "Ready to execute"');
|
||
});
|
||
|
||
test('after call: Total Plans matches argument', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 2\n`
|
||
);
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '7'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(stateContent.match(/Total Plans in Phase.*7/), 'Total Plans should be 7');
|
||
});
|
||
|
||
test('after call: Last Activity is the pinned date (deterministic via GSD_NOW_MS)', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 1\n`
|
||
);
|
||
|
||
const PINNED_MS = Date.parse('2020-09-10T15:00:00.000Z');
|
||
const PINNED_DATE = '2020-09-10';
|
||
const result = runGsdTools(
|
||
['state', 'planned-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'],
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(
|
||
stateContent.includes(PINNED_DATE),
|
||
`Last Activity should contain the pinned date ${PINNED_DATE}`,
|
||
);
|
||
});
|
||
|
||
test('missing STATE.md returns graceful error', () => {
|
||
// No STATE.md written
|
||
const result = runGsdTools(['state', 'planned-phase', '--phase', '1', '--name', 'Test', '--plans', '3'], tmpDir);
|
||
assert.ok(result.success, 'Should not crash');
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error, 'Should return error field');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// bug #1070 regression: "Complete ✓" terminal status must yield to planned-phase
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('bug #1070: "Complete ✓" terminal status yields to Ready to execute on planned-phase', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Full STATE.md shape that matches the canonical fixture used across state tests.
|
||
// Both **Status:** frontmatter and Current Position `Status:` are set to the given value.
|
||
function makeStateMd(statusValue) {
|
||
return `# Project State
|
||
|
||
**Current Phase:** 1
|
||
**Current Phase Name:** setup
|
||
**Total Phases:** 5
|
||
**Current Plan:** 0
|
||
**Total Plans in Phase:** 0
|
||
**Status:** ${statusValue}
|
||
**Last Activity:** 2026-03-20
|
||
**Last Activity Description:** Phase 1 complete
|
||
|
||
## Current Position
|
||
Phase: 1 of 5 (setup)
|
||
Plan: 0 of 5 in current phase
|
||
Status: ${statusValue}
|
||
Last activity: 2026-03-20 -- Phase 1 complete
|
||
Progress: [##########] 20%
|
||
|
||
## Decisions Made
|
||
|
||
| Phase | Decision | Rationale |
|
||
|-------|----------|-----------|
|
||
`;
|
||
}
|
||
|
||
// Case 1: the bug — Complete ✓ blocks the state machine
|
||
test('case 1: Complete ✓ in both frontmatter and Current Position is overwritten by planned-phase', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
makeStateMd('Complete ✓')
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
|
||
// The updated array must include Status (both paths ran the replacement)
|
||
assert.ok(
|
||
Array.isArray(output.updated) && output.updated.includes('Status'),
|
||
`Expected output.updated to include "Status", got: ${JSON.stringify(output.updated)}`
|
||
);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
// The checkmark form must be gone
|
||
assert.ok(
|
||
!stateContent.includes('Complete ✓'),
|
||
'STATE.md must not contain "Complete ✓" after planned-phase'
|
||
);
|
||
|
||
// Frontmatter **Status:** line must now be "Ready to execute"
|
||
const fmStatusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
|
||
assert.ok(fmStatusMatch, '**Status:** frontmatter line not found');
|
||
assert.strictEqual(
|
||
fmStatusMatch[1].trim(),
|
||
'Ready to execute',
|
||
`Frontmatter **Status:** should be "Ready to execute", got: "${fmStatusMatch[1].trim()}"`
|
||
);
|
||
|
||
// Current Position Status: line must also be "Ready to execute"
|
||
const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(posMatch, 'Current Position section not found');
|
||
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
|
||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
||
assert.strictEqual(
|
||
posStatusMatch[1].trim(),
|
||
'Ready to execute',
|
||
`Current Position Status should be "Ready to execute", got: "${posStatusMatch[1].trim()}"`
|
||
);
|
||
});
|
||
|
||
// Case 2: a genuinely executor-authored non-terminal status must NOT be overwritten
|
||
// (frontmatter **Status:** path via stateReplaceFieldIfTemplate)
|
||
test('case 2: executor-authored non-terminal status is preserved by planned-phase (#397 narrowness check)', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
makeStateMd('Blocked on infra review')
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
// The executor-authored Status must survive in the frontmatter
|
||
const fmStatusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
|
||
assert.ok(fmStatusMatch, '**Status:** frontmatter line not found');
|
||
assert.strictEqual(
|
||
fmStatusMatch[1].trim(),
|
||
'Blocked on infra review',
|
||
`Frontmatter **Status:** should be preserved as "Blocked on infra review", got: "${fmStatusMatch[1].trim()}"`
|
||
);
|
||
});
|
||
|
||
// Case 3: executor-authored non-terminal status in the Current Position section
|
||
// must NOT be overwritten (exercises updateCurrentPositionFields in src/state.cts,
|
||
// a separate code path from the frontmatter matcher).
|
||
test('case 3: executor-authored non-terminal status in Current Position is preserved by planned-phase', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
makeStateMd('Blocked on infra review')
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
// Locate the Current Position section and verify the Status line there.
|
||
const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(posMatch, 'Current Position section not found');
|
||
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
|
||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
||
assert.strictEqual(
|
||
posStatusMatch[1].trim(),
|
||
'Blocked on infra review',
|
||
`Current Position Status should be preserved as "Blocked on infra review", got: "${posStatusMatch[1].trim()}"`
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state validate (Step 4 — Gate 1)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('state validate command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('STATE says executing + VERIFICATION.md shows passed emits warning', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Executing Phase 2\n**Current Phase:** 2\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n`
|
||
);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.warnings.length > 0, 'Should have warnings when executing but verification passed');
|
||
assert.ok(output.warnings.some(w => /verif/i.test(w)), 'Warning should mention verification');
|
||
});
|
||
|
||
test('STATE plan count 3 but 12 SUMMARY.md on disk emits mismatch warning', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 3\n**Current Plan:** 1\n`
|
||
);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
// Write 12 plans and summaries
|
||
for (let i = 1; i <= 12; i++) {
|
||
const padded = String(i).padStart(2, '0');
|
||
fs.writeFileSync(path.join(phaseDir, `01-${padded}-PLAN.md`), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, `01-${padded}-SUMMARY.md`), '# Summary\n');
|
||
}
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.warnings.length > 0, 'Should have warnings for plan count mismatch');
|
||
assert.ok(output.warnings.some(w => /plan.*count|count.*mismatch/i.test(w)), 'Warning should mention plan count mismatch');
|
||
});
|
||
|
||
test('perfect state returns valid: true, no warnings', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n`
|
||
);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.valid, true, 'Should be valid');
|
||
assert.strictEqual(output.warnings.length, 0, 'Should have no warnings');
|
||
});
|
||
|
||
test('missing STATE.md returns graceful error', () => {
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, 'Should not crash');
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error, 'Should return error field');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state sync (Step 5 — Gate 2)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('state sync command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('drifted STATE.md + correct filesystem: after sync, fields match disk', () => {
|
||
// STATE says phase 1 with 0 plans, but disk has phase 2 with 3 plans
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Planning\n**Current Phase:** 1\n**Total Plans in Phase:** 0\n**Current Plan:** 0\n**Progress:** 0%\n`
|
||
);
|
||
|
||
const phase1Dir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||
fs.mkdirSync(phase1Dir, { recursive: true });
|
||
fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
const phase2Dir = path.join(tmpDir, '.planning', 'phases', '02-core');
|
||
fs.mkdirSync(phase2Dir, { recursive: true });
|
||
fs.writeFileSync(path.join(phase2Dir, '02-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase2Dir, '02-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase2Dir, '02-03-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phase2Dir, '02-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.synced, 'Should report synced');
|
||
|
||
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
// Total plans in current phase (phase 2 since it's highest with incomplete plans) should be 3
|
||
assert.ok(stateAfter.match(/Total Plans in Phase.*3/), 'Total Plans should match disk (3)');
|
||
});
|
||
|
||
test('run sync twice is idempotent', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n**Progress:** 0%\n`
|
||
);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
runGsdTools('state sync', tmpDir);
|
||
const afterFirst = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
runGsdTools('state sync', tmpDir);
|
||
const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
// Strip frontmatter timestamps which will differ
|
||
const stripTimestamps = (s) => s.replace(/last_updated:.*\r?\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS');
|
||
assert.strictEqual(stripTimestamps(afterFirst), stripTimestamps(afterSecond), 'Two syncs should produce same result');
|
||
});
|
||
|
||
test('--verify flag reports changes without writing', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Status:** Planning\n**Current Phase:** 1\n**Total Plans in Phase:** 0\n**Current Plan:** 0\n**Progress:** 0%\n`
|
||
);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||
|
||
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
|
||
const result = runGsdTools('state sync --verify', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.changes && output.changes.length > 0, 'Should report changes');
|
||
assert.strictEqual(output.dry_run, true, 'Should indicate dry run');
|
||
|
||
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.strictEqual(before, after, 'File should not be modified in verify mode');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #2444: stopped_at frontmatter must not be overwritten by historical body prose
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('stopped_at frontmatter not overwritten by historical prose (bug #2444)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state sync preserves correct stopped_at frontmatter when historical plain-text match appears before Session section', () => {
|
||
// The bug: body has plain "Stopped at:" in old notes (no bold) — stateExtractField
|
||
// uses a plain ^Stopped at:\s*(.+) pattern with /im which matches the first line,
|
||
// returning the stale historical value. syncStateFrontmatter has no preservation
|
||
// step for stopped_at like cmdStateJson does, so it overwrites the correct value.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---
|
||
gsd_state_version: '1.0'
|
||
status: executing
|
||
stopped_at: Phase 3, Plan 2 — current correct value
|
||
---
|
||
|
||
# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Status:** In progress
|
||
|
||
## Previous Session Notes
|
||
|
||
Stopped at: Phase 5 complete — v1.0 shipped (OLD stale historical note)
|
||
|
||
## Session
|
||
|
||
Last Date: 2026-04-19
|
||
Stopped At: Phase 3, Plan 2 — current correct value
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
// The correct frontmatter value must survive the sync
|
||
assert.ok(
|
||
stateContent.includes('Phase 3, Plan 2 — current correct value'),
|
||
'stopped_at must retain the correct value from the ## Session section'
|
||
);
|
||
assert.ok(
|
||
!stateContent.includes('stopped_at: Phase 5 complete'),
|
||
'stopped_at must NOT be overwritten with the old historical note'
|
||
);
|
||
});
|
||
|
||
test('state sync does not promote stale body prose to stopped_at frontmatter when frontmatter has no stopped_at', () => {
|
||
// No existing stopped_at in frontmatter, body has plain Stopped at: in
|
||
// a historical notes section appearing BEFORE the real ## Session entry.
|
||
// buildStateFrontmatter should scope extraction to ## Session section, not
|
||
// match the first occurrence anywhere in the body.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`---
|
||
gsd_state_version: '1.0'
|
||
status: executing
|
||
---
|
||
|
||
# Project State
|
||
|
||
**Current Phase:** 03
|
||
**Status:** In progress
|
||
|
||
## Old Notes
|
||
|
||
Stopped at: Phase 5 complete — v1.0 STALE (should never land in frontmatter)
|
||
|
||
## Session
|
||
|
||
Last Date: 2026-04-19
|
||
Stopped At: Phase 3, Plan 1 — real current value
|
||
`
|
||
);
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const output = JSON.parse(jsonResult.output);
|
||
|
||
assert.strictEqual(output.stopped_at, 'Phase 3, Plan 1 — real current value',
|
||
'stopped_at must be extracted from ## Session section, not the first plain-text match in the body');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #2445: stale phase dirs from closed milestone inflate phase counts
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('stale phase dirs do not corrupt phase counts (bug #2445)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state json excludes stale prior-milestone phase dirs from phase count when ROADMAP scopes current milestone', () => {
|
||
// Old milestone had phases 1-5; new milestone starts fresh with phases 1-2.
|
||
// Stale dirs for old phases 3, 4, 5 remain in .planning/phases/ and must be
|
||
// excluded by getMilestonePhaseFilter (new ROADMAP only lists phases 1 and 2).
|
||
// Old phases 1 and 2 dirs are ambiguous (same number reused) but phase 3-5 dirs
|
||
// must not inflate total_phases beyond the ROADMAP's phaseCount of 2.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'<details>',
|
||
'<summary>v1.0 — Old Milestone (Shipped)</summary>',
|
||
'',
|
||
'## Roadmap v1.0: Old Milestone',
|
||
'### Phase 1: Old Foundation',
|
||
'### Phase 2: Old API',
|
||
'### Phase 3: Old Deploy',
|
||
'### Phase 4: Old Polish',
|
||
'### Phase 5: Old Wrap',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## Roadmap v2.0: New Milestone',
|
||
'### Phase 1: New Foundation',
|
||
'### Phase 2: New API',
|
||
].join('\n')
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'---\nmilestone: v2.0\n---\n\n# State\n\n**Current Phase:** 01\n**Status:** Planning\n'
|
||
);
|
||
|
||
// Create stale v1.0 phase dirs 3, 4, 5 — these are NOT in the new ROADMAP
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
for (const dir of ['03-old-deploy', '04-old-polish', '05-old-wrap']) {
|
||
const d = path.join(phasesDir, dir);
|
||
fs.mkdirSync(d, { recursive: true });
|
||
fs.writeFileSync(path.join(d, `${dir.slice(0, 2)}-01-PLAN.md`), '# stale plan\n');
|
||
}
|
||
|
||
// New milestone has only Phase 1 started so far
|
||
const newPhaseDir = path.join(phasesDir, '01-new-foundation');
|
||
fs.mkdirSync(newPhaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(newPhaseDir, '01-01-PLAN.md'), '# new plan\n');
|
||
|
||
const result = runGsdTools('state json', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
|
||
// total_phases must be bounded by the ROADMAP's 2 phases, not 4 total dirs
|
||
// (the 3 stale dirs for phases 3-5 must be excluded by the milestone filter)
|
||
assert.ok(
|
||
output.progress && output.progress.total_phases <= 2,
|
||
`total_phases should be ≤ 2 (new milestone phases 1-2 only), got ${output.progress?.total_phases}`
|
||
);
|
||
// total_plans must only count plans from current-milestone phase dirs
|
||
assert.ok(
|
||
output.progress && output.progress.total_plans <= 1,
|
||
`total_plans should be 1 (only new phase 1 dir), got ${output.progress?.total_plans}`
|
||
);
|
||
});
|
||
|
||
test('init new-milestone phase_dir_count excludes stale prior-milestone dirs', () => {
|
||
// ROADMAP scoped to v2.0 with 2 phases
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'<details>',
|
||
'<summary>v1.0 — Shipped</summary>',
|
||
'',
|
||
'## Roadmap v1.0: Old',
|
||
'### Phase 1: Old One',
|
||
'### Phase 2: Old Two',
|
||
'### Phase 3: Old Three',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## Roadmap v2.0: New',
|
||
'### Phase 1: New One',
|
||
'### Phase 2: New Two',
|
||
].join('\n')
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'---\nmilestone: v2.0\n---\n\n# State\n\n**Status:** Planning\n'
|
||
);
|
||
|
||
// Three stale phase dirs from the old milestone
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
for (const dir of ['01-old-one', '02-old-two', '03-old-three']) {
|
||
fs.mkdirSync(path.join(phasesDir, dir), { recursive: true });
|
||
}
|
||
|
||
const result = runGsdTools('init new-milestone', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
|
||
// phase_dir_count should not include stale dirs from the old milestone
|
||
assert.ok(
|
||
output.phase_dir_count <= 2,
|
||
`phase_dir_count should be ≤ 2 (only new-milestone dirs), got ${output.phase_dir_count}`
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state complete-phase: Phase-fallback decoration handling (PR #2761 nitpick)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// When STATE.md is missing the canonical `**Current Phase:**` field but
|
||
// includes a decorated `## Current Position` body line, the fallback path used
|
||
// to leak the decoration into downstream Status/Phase strings — producing
|
||
// `**Status:** Phase 01 (Foo) — EXECUTING complete` instead of the expected
|
||
// `**Status:** Phase 01 complete`. CodeRabbit flagged this on PR #2761 and the
|
||
// Phase fallback now strips everything past the leading numeric/decimal token.
|
||
describe('state complete-phase: decorated Phase fallback (#2761 nitpick)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('writes clean Phase identifier when only Current Position decoration is present', () => {
|
||
// STATE.md without the canonical `**Current Phase:**` field — the only
|
||
// phase signal lives inside the `## Current Position` block as a decorated
|
||
// line. This is the regression fixture.
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** Executing',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 01 (Foo) — EXECUTING',
|
||
'Plan: bootstrap',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state complete-phase', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'utf-8',
|
||
);
|
||
|
||
// Status should reference the bare phase identifier (`01`), not the
|
||
// decorated string. The negative assertion catches the regression
|
||
// shape directly.
|
||
assert.ok(
|
||
updated.includes('**Status:** Phase 01 complete'),
|
||
`Status should be "Phase 01 complete", got STATE.md:\n${updated}`,
|
||
);
|
||
assert.ok(
|
||
!updated.includes('Phase 01 (Foo) — EXECUTING complete'),
|
||
`Status must not embed Current Position decoration: ${updated}`,
|
||
);
|
||
});
|
||
|
||
test('canonical Current Phase field is preferred over Current Position decoration', () => {
|
||
// When both are present, Current Phase wins — same outcome as before, but
|
||
// pinned here so a future refactor that flips precedence is caught.
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** Executing',
|
||
'**Current Phase:** 03',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 01 (Foo) — EXECUTING',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state complete-phase', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const updated = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'utf-8',
|
||
);
|
||
assert.ok(
|
||
updated.includes('**Status:** Phase 03 complete'),
|
||
`Status should reference canonical Current Phase (03), got: ${updated}`,
|
||
);
|
||
});
|
||
|
||
test('rejects unresolved literal Phase token and does not corrupt STATE.md (#3063)', () => {
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** Executing',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: narrative only',
|
||
'',
|
||
].join('\n');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, stateMd);
|
||
|
||
const result = runGsdTools('state complete-phase', tmpDir);
|
||
assert.ok(result.success, 'command should return JSON error payload, not crash');
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error, 'expected clear resolution error');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(!after.includes('Phase: Phase — COMPLETE'));
|
||
assert.ok(!after.includes('Status: Phase Phase complete'));
|
||
});
|
||
|
||
test('rejects a milestone-closure Phase line, never mines the version token (#2111 / #2125)', () => {
|
||
// After `milestone complete v0.5`, the only phase signal is the narrative
|
||
// `Phase: Milestone v0.5 complete`. The old unanchored resolver mined "0.5"
|
||
// and rewrote Status as "Phase 0.5 complete"; the anchored parser yields no
|
||
// token, so complete-phase must reject rather than corrupt STATE.md.
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v0.5',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** Awaiting next milestone',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: Milestone v0.5 complete',
|
||
'',
|
||
].join('\n');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, stateMd);
|
||
|
||
const result = runGsdTools('state complete-phase', tmpDir);
|
||
assert.ok(result.success, 'command should return JSON error payload, not crash');
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.error, 'expected a resolution error, not a phase mined from the version string');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(!after.includes('Phase 0.5 complete'), `must not mine "0.5" from the version: ${after}`);
|
||
assert.ok(!after.includes('Phase: 0.5'), `must not rewrite Current Position to Phase 0.5: ${after}`);
|
||
});
|
||
|
||
test('supports explicit phase override for complete-phase disambiguation (#3063)', () => {
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** Executing',
|
||
'**Last Activity:** 2024-01-15',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: narrative only',
|
||
'',
|
||
].join('\n');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, stateMd);
|
||
|
||
const result = runGsdTools('state complete-phase --phase 3.3', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('**Status:** Phase 3.3 complete'));
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// summary-extract command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// state add-roadmap-evolution (regression: bug #1140)
|
||
//
|
||
// `query state.add-roadmap-evolution` was unreachable: the CJS state router
|
||
// listed it in its `unsupported` map with a message pointing back at the exact
|
||
// command that just failed ("...is SDK-only. Use: gsd-tools query
|
||
// state.add-roadmap-evolution ..."), and no CJS handler existed after the SDK
|
||
// retirement (ADR-0174). Every `/gsd:phase insert` and `/gsd:phase --edit` run
|
||
// hit a circular dead end. The fix re-implements `cmdStateAddRoadmapEvolution`
|
||
// in CJS and wires it into the state router. These cases follow the CLI/parser
|
||
// QA matrix in CONTRIBUTING.md (all invocations use argv arrays, no shell).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe('state add-roadmap-evolution (bug #1140)', () => {
|
||
let tmpDir;
|
||
|
||
const STATE_WITH_ACC_CONTEXT = `# Project State
|
||
|
||
## Current Status
|
||
|
||
**Current Phase:** 103.1
|
||
|
||
## Accumulated Context
|
||
|
||
### Decisions
|
||
|
||
- Some earlier decision
|
||
`;
|
||
|
||
const writeState = (dir, body) => fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), body);
|
||
const readState = (dir) => fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf-8');
|
||
// Body of `## Accumulated Context` bounded by the next h2 (or EOF), so
|
||
// placement assertions prove a subsection sits INSIDE that section.
|
||
const accumulatedContextBody = (state) => {
|
||
const m = state.match(/##\s*Accumulated Context\s*\r?\n([\s\S]*?)(?=\n##[^#]|$)/);
|
||
return m ? m[1] : null;
|
||
};
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createFixture();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// The literal issue repro: negative proof the circular dead end is gone.
|
||
test('query state.add-roadmap-evolution no longer routes to the circular SDK-only rejection', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const result = runGsdTools(
|
||
['query', 'state.add-roadmap-evolution',
|
||
'--phase', '103.2', '--action', 'inserted', '--after', '103.1',
|
||
'--note', 'test', '--urgent'],
|
||
tmpDir
|
||
);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.ok(
|
||
!/SDK-only/i.test(result.output) && !/SDK-only/i.test(result.error || ''),
|
||
`must not emit the circular "SDK-only" rejection; got output=${result.output} error=${result.error}`
|
||
);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.added, true);
|
||
assert.match(parsed.entry, /\(URGENT\)$/);
|
||
});
|
||
|
||
test('appends an entry, creating the ### Roadmap Evolution subsection under ## Accumulated Context', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution',
|
||
'--phase', '103.2', '--action', 'inserted', '--after', '103.1',
|
||
'--note', 'Add OAuth login', '--urgent'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.ok(
|
||
state.includes('- Phase 103.2 inserted after Phase 103.1: Add OAuth login (URGENT)'),
|
||
`entry not found in:\n${state}`
|
||
);
|
||
assert.strictEqual((state.match(/^### Roadmap Evolution$/gm) || []).length, 1, 'subsection must not be duplicated');
|
||
const accBody = accumulatedContextBody(state);
|
||
assert.ok(accBody && accBody.includes('### Roadmap Evolution'), 'subsection must be inside Accumulated Context');
|
||
assert.ok(accBody.includes('- Phase 103.2 inserted after Phase 103.1: Add OAuth login (URGENT)'), 'entry must be inside Accumulated Context');
|
||
assert.ok(state.includes('- Some earlier decision'), 'existing content preserved');
|
||
});
|
||
|
||
test('omitting --urgent and --after produces a plain entry', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '103.2', '--action', 'edited',
|
||
'--note', 'edited fields: goal, depends_on'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes('- Phase 103.2 edited: edited fields: goal, depends_on'), `missing entry:\n${state}`);
|
||
assert.ok(!/\(URGENT\)/.test(state), 'no URGENT suffix when --urgent absent');
|
||
});
|
||
|
||
test('creates ### Roadmap Evolution when ## Accumulated Context exists without it', () => {
|
||
writeState(tmpDir, `# Project State
|
||
|
||
## Accumulated Context
|
||
|
||
### Decisions
|
||
|
||
- prior decision
|
||
|
||
## Next Steps
|
||
|
||
- do the thing
|
||
`);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '4', '--action', 'added', '--note', 'caching layer'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes('### Roadmap Evolution'), 'subsection created');
|
||
assert.ok(state.includes('- Phase 4 added: caching layer'), 'entry appended');
|
||
const subIdx = state.indexOf('### Roadmap Evolution');
|
||
const nextIdx = state.indexOf('## Next Steps');
|
||
assert.ok(subIdx !== -1 && nextIdx !== -1 && subIdx < nextIdx, 'subsection must be inside Accumulated Context');
|
||
assert.ok(state.includes('- do the thing'), 'sibling section preserved');
|
||
});
|
||
|
||
test('creates both ## Accumulated Context and ### Roadmap Evolution when neither exists', () => {
|
||
writeState(tmpDir, `# Project State
|
||
|
||
## Current Status
|
||
|
||
**Current Phase:** 1
|
||
`);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'inserted', '--after', '1', '--note', 'bootstrap'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.strictEqual(JSON.parse(result.output).added, true);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.strictEqual((state.match(/^## Accumulated Context$/gm) || []).length, 1, 'Accumulated Context created once');
|
||
assert.strictEqual((state.match(/^### Roadmap Evolution$/gm) || []).length, 1, 'subsection created once');
|
||
assert.ok(state.includes('- Phase 2 inserted after Phase 1: bootstrap'), 'entry appended');
|
||
});
|
||
|
||
test('targets the subsection under Accumulated Context, never a decoy heading elsewhere', () => {
|
||
writeState(tmpDir, `# Project State
|
||
|
||
## Accumulated Context
|
||
|
||
### Decisions
|
||
|
||
- prior decision
|
||
|
||
## Reference Notes
|
||
|
||
### Roadmap Evolution
|
||
|
||
- DECOY entry that must never be touched
|
||
`);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '8', '--action', 'inserted', '--note', 'real entry'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
const accBody = accumulatedContextBody(state);
|
||
assert.ok(accBody && accBody.includes('- Phase 8 inserted: real entry'), 'entry must be inside Accumulated Context');
|
||
assert.ok(state.includes('- DECOY entry that must never be touched'), 'decoy preserved');
|
||
assert.ok(!accBody.includes('DECOY'), 'decoy must not be pulled into Accumulated Context');
|
||
assert.strictEqual((state.match(/^### Roadmap Evolution$/gm) || []).length, 2, 'a new subsection is created under Accumulated Context; decoy heading remains');
|
||
});
|
||
|
||
test('flattens a multiline note into a single bullet so dedupe and rendering hold', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const notePath = path.join(tmpDir, 'note.txt');
|
||
fs.writeFileSync(notePath, 'line one\nline two\nline three\n');
|
||
|
||
const first = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '9', '--action', 'edited', '--note-file', notePath],
|
||
tmpDir
|
||
);
|
||
assert.ok(first.success, `Command failed: ${first.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes('- Phase 9 edited: line one line two line three'), `note not flattened:\n${state}`);
|
||
assert.ok(!/\r?\n\s*line two/.test(state), 'continuation lines must not spill outside the bullet');
|
||
|
||
const second = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '9', '--action', 'edited', '--note-file', notePath],
|
||
tmpDir
|
||
);
|
||
assert.strictEqual(JSON.parse(second.output).reason, 'duplicate', 'flattened entry must dedupe on replay');
|
||
});
|
||
|
||
test('deduplicates an identical entry on replay', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const args = ['state', 'add-roadmap-evolution', '--phase', '103.2', '--action', 'inserted',
|
||
'--after', '103.1', '--note', 'Add OAuth login', '--urgent'];
|
||
|
||
const first = runGsdTools(args, tmpDir);
|
||
assert.ok(first.success, `first call failed: ${first.error}`);
|
||
assert.strictEqual(JSON.parse(first.output).added, true);
|
||
|
||
const second = runGsdTools(args, tmpDir);
|
||
assert.ok(second.success, `second call failed: ${second.error}`);
|
||
const parsed = JSON.parse(second.output);
|
||
assert.strictEqual(parsed.added, false, 'replay must not add');
|
||
assert.strictEqual(parsed.reason, 'duplicate');
|
||
|
||
const state = readState(tmpDir);
|
||
const occurrences = (state.match(/- Phase 103\.2 inserted after Phase 103\.1: Add OAuth login \(URGENT\)/g) || []).length;
|
||
assert.strictEqual(occurrences, 1, 'entry must appear exactly once after replay');
|
||
});
|
||
|
||
test('CRLF STATE.md: appends under Accumulated Context while preserving later sections', () => {
|
||
const crlf = [
|
||
'# Project State', '',
|
||
'## Accumulated Context', '',
|
||
'### Decisions', '',
|
||
'- prior decision', '',
|
||
'## Blockers', '',
|
||
'- keep me', '',
|
||
'## History', '',
|
||
'- also keep me', '',
|
||
].join('\r\n');
|
||
writeState(tmpDir, crlf);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '4', '--action', 'inserted', '--note', 'crlf safe'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes('## Blockers'), '## Blockers must be preserved');
|
||
assert.strictEqual((state.match(/^## Blockers/gm) || []).length, 1, '## Blockers not duplicated/corrupted');
|
||
assert.ok(state.includes('- keep me'), 'Blockers content must be preserved');
|
||
assert.ok(state.includes('## History'), '## History must be preserved');
|
||
assert.ok(state.includes('- also keep me'), 'History content must be preserved');
|
||
assert.ok(/### Roadmap Evolution/.test(state), 'subsection created');
|
||
assert.ok(/- Phase 4 inserted: crlf safe/.test(state), 'entry appended');
|
||
});
|
||
|
||
test('missing --note is rejected without mutating STATE.md', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
const before = readState(tmpDir);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'inserted'],
|
||
tmpDir
|
||
);
|
||
const combined = `${result.output}\n${result.error || ''}`;
|
||
assert.match(combined, /note required/, 'should report the missing-note error');
|
||
assert.ok(!/"added"\s*:\s*true/.test(result.output), 'must not report added:true');
|
||
assert.ok(!/\bat .*\(.*:\d+:\d+\)/.test(result.error || ''), 'no stack trace in failure output');
|
||
assert.strictEqual(readState(tmpDir), before, 'STATE.md not mutated on missing note');
|
||
});
|
||
|
||
test('empty --note "" is rejected without mutating STATE.md', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
const before = readState(tmpDir);
|
||
|
||
runGsdTools(['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'inserted', '--note', ''], tmpDir);
|
||
assert.strictEqual(readState(tmpDir), before, 'STATE.md must be untouched for empty note');
|
||
});
|
||
|
||
test('whitespace-only --note is rejected without mutating STATE.md', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
const before = readState(tmpDir);
|
||
|
||
runGsdTools(['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'inserted', '--note', ' '], tmpDir);
|
||
assert.strictEqual(readState(tmpDir), before, 'STATE.md must be untouched for whitespace-only note');
|
||
});
|
||
|
||
test('--note followed by a flag-shaped token is treated as missing note', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
const before = readState(tmpDir);
|
||
|
||
runGsdTools(['state', 'add-roadmap-evolution', '--phase', '5', '--note', '--weird'], tmpDir);
|
||
assert.strictEqual(readState(tmpDir), before, 'flag-shaped value must not be consumed as the note');
|
||
});
|
||
|
||
test('duplicate --phase flags do not crash; first value wins', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '7', '--phase', '9', '--action', 'inserted', '--note', 'dup flags'],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes('- Phase 7 inserted: dup flags'), `expected phase 7 entry:\n${state}`);
|
||
assert.ok(!state.includes('Phase 9'), 'second --phase value must not be used');
|
||
});
|
||
|
||
test('shell metacharacters in --note are stored literally, never executed', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
// Probe path lives under the test's tmpDir (no hardcoded /tmp literal, which
|
||
// the Windows-parity guard forbids). If command substitution executed, this
|
||
// file would exist afterward.
|
||
const probe = path.join(tmpDir, 'gsd-pwn-1140');
|
||
const hostile = `pwn $(touch ${probe}) \`id\` ; rm -rf / && echo done`;
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'inserted', '--note', hostile],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const state = readState(tmpDir);
|
||
assert.ok(state.includes(hostile), 'hostile note must be stored verbatim');
|
||
assert.ok(!fs.existsSync(probe), 'command substitution must not have executed');
|
||
});
|
||
|
||
test('Unicode note content is preserved', () => {
|
||
writeState(tmpDir, STATE_WITH_ACC_CONTEXT);
|
||
|
||
const note = 'café — 日本語 — 🚀 reroute';
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'edited', '--note', note],
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.ok(readState(tmpDir).includes(note), 'Unicode preserved');
|
||
});
|
||
|
||
test('missing STATE.md returns a structured error, not a crash', () => {
|
||
// Guarantee STATE.md is absent (force: no-op if the fixture didn't create one).
|
||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- deleting a single fixture file to simulate the missing-STATE.md case, not a temp-dir teardown
|
||
fs.rmSync(path.join(tmpDir, '.planning', 'STATE.md'), { force: true });
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-roadmap-evolution', '--phase', '5', '--action', 'inserted', '--note', 'x'],
|
||
tmpDir
|
||
);
|
||
const combined = `${result.output}\n${result.error || ''}`;
|
||
assert.match(combined, /STATE\.md not found/, 'should report STATE.md not found');
|
||
assert.ok(!/\bat .*\(.*:\d+:\d+\)/.test(result.error || ''), 'no stack trace');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// regressions: table-format STATE.md (#1162)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Minimal STATE.md that uses a pipe-table for the Current Position section.
|
||
* This is the format that triggered the "Field not found" silent failure.
|
||
*/
|
||
function buildTableFormatState(opts) {
|
||
const {
|
||
status = 'Ready to plan',
|
||
phase = '3',
|
||
planCount = '4',
|
||
lastActivity = '2026-01-01',
|
||
} = opts || {};
|
||
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Field | Value |',
|
||
'| --- | --- |',
|
||
`| Status | ${status} |`,
|
||
`| Phase | ${phase} |`,
|
||
`| Total Plans in Phase | ${planCount} |`,
|
||
`| Last Activity | ${lastActivity} |`,
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'Some context here.',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* STATE.md that uses bold inline format (the existing working format).
|
||
* Included as a control case to confirm we did not break bold-field support.
|
||
*/
|
||
function buildBoldFormatState(opts) {
|
||
const {
|
||
status = 'Ready to plan',
|
||
phase = '3',
|
||
} = opts || {};
|
||
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
`**Status:** ${status}`,
|
||
`**Phase:** ${phase}`,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
describe('regressions: table-format STATE.md (#1162)', () => {
|
||
let tmpDir;
|
||
let statePath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('gsd-1162-');
|
||
statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// ── Happy path: table-format field replacement ──────────────────────────
|
||
|
||
test('state update rewrites table-cell Status value', () => {
|
||
fs.writeFileSync(statePath, buildTableFormatState({ status: 'Ready to plan' }));
|
||
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||
|
||
// Command must report success
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'expected updated:true but got: ' + JSON.stringify(parsed));
|
||
|
||
// The table cell must be rewritten on disk
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('| Status | Ready to execute |'),
|
||
'Table cell not rewritten. STATE.md content:\n' + written,
|
||
);
|
||
// Original value must be gone
|
||
assert.ok(
|
||
!written.includes('| Status | Ready to plan |'),
|
||
'Old table cell value still present in STATE.md',
|
||
);
|
||
});
|
||
|
||
test('state update rewrites table-cell value for arbitrary field', () => {
|
||
fs.writeFileSync(statePath, buildTableFormatState({ lastActivity: '2026-01-01' }));
|
||
|
||
const result = runGsdTools(['state', 'update', 'Last Activity', '2026-06-13'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'expected updated:true');
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('| Last Activity | 2026-06-13 |'),
|
||
'Last Activity table cell not rewritten. Content:\n' + written,
|
||
);
|
||
});
|
||
|
||
test('state update is case-insensitive for table field names', () => {
|
||
// Table may have lowercase "status" in the first cell
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'---',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Field | Value |',
|
||
'| --- | --- |',
|
||
'| status | Ready to plan |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools(['state', 'update', 'status', 'Ready to execute'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'case-insensitive table match failed');
|
||
});
|
||
|
||
// ── Negative: separator row must NOT be treated as a field ───────────────
|
||
|
||
test('separator row | --- | --- | is not matched as a field', () => {
|
||
// The field name "---" is rejected by the field-name validator before
|
||
// stateReplaceField is even called. The command exits with a non-zero
|
||
// status and a plain-text error, NOT a JSON { updated: false } result.
|
||
// The key invariant is that the file is never corrupted.
|
||
const originalContent = buildTableFormatState();
|
||
fs.writeFileSync(statePath, originalContent);
|
||
|
||
const result = runGsdTools(['state', 'update', '---', 'injected'], tmpDir);
|
||
|
||
// The validator rejects '---' as an invalid field name — command must fail
|
||
// OR, if somehow the command succeeds, updated must be false.
|
||
if (result.success) {
|
||
// Unlikely path — if the validator is relaxed in future, still must not update.
|
||
let parsed;
|
||
try { parsed = JSON.parse(result.output); } catch { parsed = null; }
|
||
if (parsed) {
|
||
assert.equal(parsed.updated, false, 'separator row incorrectly matched as a field');
|
||
}
|
||
}
|
||
// Either way: the file must be untouched (no 'injected' value written)
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(!written.includes('injected'), 'separator row replacement leaked into file');
|
||
});
|
||
|
||
// ── Regression: bold-format still works after the fix ────────────────────
|
||
|
||
test('state update bold-format still works after table support added', () => {
|
||
fs.writeFileSync(statePath, buildBoldFormatState({ status: 'Ready to plan' }));
|
||
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'bold-format update broken after fix');
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('**Status:** Ready to execute'),
|
||
'Bold-format field not rewritten. Content:\n' + written,
|
||
);
|
||
});
|
||
|
||
// ── updateCurrentPositionFields table support ─────────────────────────────
|
||
|
||
test('state planned-phase updates table-cell Status via updateCurrentPositionFields', () => {
|
||
// cmdStatePlannedPhase uses updateCurrentPositionFields internally;
|
||
// verify it also handles the table format.
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'**Status:** Ready to plan',
|
||
'**Total Plans in Phase:** 0',
|
||
'**Last Activity:** 2026-01-01',
|
||
'**Last Activity Description:** initial',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Field | Value |',
|
||
'| --- | --- |',
|
||
'| Status | Ready to plan |',
|
||
'| Last Activity | 2026-01-01 |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// Create a minimal phase dir so planned-phase can count plans
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '1-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '1-01-PLAN.md'), '# Plan 1');
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '1', '--plan-count', '1'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
// The Current Position table cell should now be "Ready to execute"
|
||
assert.ok(
|
||
written.includes('| Status | Ready to execute |'),
|
||
'planned-phase did not update table-cell Status. Content:\n' + written,
|
||
);
|
||
});
|
||
|
||
// ── Adversarial / edge cases ──────────────────────────────────────────────
|
||
|
||
test('table field with extra whitespace in cells is handled', () => {
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'---',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Status | Ready to plan |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'extra-whitespace table cell not matched');
|
||
});
|
||
|
||
test('updating one row in a multi-row table does not corrupt adjacent rows', () => {
|
||
// Regression: updating `Status` must leave the `Phase` row untouched.
|
||
// NOTE: values containing literal '|' (e.g., "blocked | waiting") are NOT
|
||
// supported — the current value regex [^|\n]*? stops at the first pipe.
|
||
// Escaped-pipe values are out of scope for single-token status fields.
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'---',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Status | Ready to plan |',
|
||
'| Phase | 3 |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// Normal replacement — verify Phase row is untouched
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(written.includes('| Phase | 3 |'), 'Phase row was corrupted during Status update');
|
||
});
|
||
|
||
test('CRLF line endings in table format are handled', () => {
|
||
const content = buildTableFormatState({ status: 'Ready to plan' }).replace(/\r?\n/g, '\r\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, true, 'CRLF table format not handled');
|
||
});
|
||
|
||
test('missing STATE.md returns updated:false gracefully', () => {
|
||
// No STATE.md written — verify the command does not throw
|
||
const missingDir = createTempProject('gsd-1162-missing-');
|
||
try {
|
||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], missingDir);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.equal(parsed.updated, false, 'missing STATE.md should return updated:false');
|
||
} finally {
|
||
cleanup(missingDir);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFields preserve-authored invariants', () => {
|
||
let tmpDir;
|
||
let statePath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('gsd-1162-f2-');
|
||
statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Helper: a STATE.md with table-format Current Position section.
|
||
// We use planned-phase to exercise updateCurrentPositionFields indirectly,
|
||
// because that is the call-site that writes Status/Last Activity.
|
||
function buildMixedFormatState(opts) {
|
||
const {
|
||
status = 'Ready to plan',
|
||
lastActivity = '2026-01-01',
|
||
} = opts || {};
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'**Status:** Ready to plan',
|
||
'**Total Plans in Phase:** 0',
|
||
`**Last Activity:** ${lastActivity}`,
|
||
'**Last Activity Description:** initial',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'| Field | Value |',
|
||
'| --- | --- |',
|
||
`| Status | ${status} |`,
|
||
`| Last Activity | ${lastActivity} |`,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// (a) Custom Status in table format must NOT be overwritten by planned-phase.
|
||
test('(Finding 2a) custom Status in table format is preserved by updateCurrentPositionFields', () => {
|
||
// "Blocked: waiting on infra" is executor-authored — not in KNOWN_TEMPLATE_DEFAULTS.
|
||
// planned-phase calls updateCurrentPositionFields with status="Ready to execute".
|
||
// The table-format branch must honour the same guard as the inline branch:
|
||
// only overwrite when the existing value is a known template default.
|
||
const content = buildMixedFormatState({ status: 'Blocked: waiting on infra', lastActivity: '2026-01-01' });
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '2-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('| Status | Blocked: waiting on infra |'),
|
||
'Custom Status was overwritten by updateCurrentPositionFields table branch.\nContent:\n' + written,
|
||
);
|
||
assert.ok(
|
||
!written.includes('| Status | Ready to execute |'),
|
||
'Custom Status replaced with Ready to execute in table branch.\nContent:\n' + written,
|
||
);
|
||
});
|
||
|
||
// (b) Narrative Last Activity in table format must NOT be overwritten.
|
||
test('(Finding 2b) narrative Last Activity in table format is preserved', () => {
|
||
// "2026-02-15 -- blocked" has trailing prose — executor-authored.
|
||
// planned-phase calls updateCurrentPositionFields with today's ISO date.
|
||
// Must be preserved.
|
||
const content = buildMixedFormatState({ status: 'Ready to plan', lastActivity: '2026-02-15 -- blocked' });
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '2-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('| Last Activity | 2026-02-15 -- blocked |'),
|
||
'Narrative Last Activity was overwritten in table branch.\nContent:\n' + written,
|
||
);
|
||
});
|
||
|
||
// (c) Known-default Status and bare-date Last Activity ARE updated.
|
||
test('(Finding 2c) known-default Status and bare-date Last Activity ARE updated in table format', () => {
|
||
// "Ready to plan" is a known default; "2026-01-01" is a bare ISO date.
|
||
// Both should be replaced by planned-phase.
|
||
const content = buildMixedFormatState({ status: 'Ready to plan', lastActivity: '2026-01-01' });
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '2-test-phase');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '2-01-PLAN.md'), '# Plan\n');
|
||
|
||
const result = runGsdTools(['state', 'planned-phase', '2', '--plan-count', '1'], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(
|
||
written.includes('| Status | Ready to execute |'),
|
||
'Known-default Status not updated in table branch.\nContent:\n' + written,
|
||
);
|
||
// Last Activity should be today's date (not 2026-01-01)
|
||
assert.ok(
|
||
!written.includes('| Last Activity | 2026-01-01 |'),
|
||
'Bare-date Last Activity was NOT updated in table branch.\nContent:\n' + written,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// #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*\r?\n([\s\S]*?)(?=\r?\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*\r?\n([\s\S]*?)(?=\r?\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);
|
||
}
|
||
});
|
||
});
|
||
|
||
// #1257 — planned-phase + begin-phase pipe-table regressions
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Regression tests for bug #1257.
|
||
*
|
||
* Finding 1 (INFERRED — reproduced here empirically):
|
||
* `cmdStatePlannedPhase` calls `stateReplaceFieldIfTemplate(content, 'Status', …)`
|
||
* on the FULL file content (including YAML frontmatter). The plain-text pattern
|
||
* `^Status:\s*(.+)` (case-insensitive) matches the YAML frontmatter `status: planning`
|
||
* line BEFORE reaching the body pipe-table row `| Status | Planning |`. The pipe-table
|
||
* cell is never updated. `syncStateFrontmatter` re-derives from the unchanged body
|
||
* and the #1230 delta heuristic preserves the original frontmatter value, so the
|
||
* status never advances to 'Ready to execute'.
|
||
* Smoking-gun: src/state.cts:2015 — `stateReplaceFieldIfTemplate(content, 'Status', …)`
|
||
* where `content` is the full file (frontmatter + body), not stripped body.
|
||
*
|
||
* Finding 2 (OBSERVED):
|
||
* `cmdStateBeginPhase`'s `## Current Position` update block only has pipe-table
|
||
* else-branches for Status (#1255) and Last-activity (#1255), NOT for Phase or Plan.
|
||
* For a pipe-table STATE.md, the `| Phase | … |` and `| Plan | … |` rows are silently
|
||
* ignored: the else-branch instead INSERTS a new inline `Phase: N — EXECUTING` text
|
||
* line prepended to the section body (leaving the old table cells stale).
|
||
* Smoking-gun: src/state.cts:1833–1844 — `^Phase:` / `^Plan:` plain-text checks with
|
||
* else-branches that prepend text rather than calling stateReplaceField on the table.
|
||
*/
|
||
|
||
// STATE.md fixture for #1257 — pipe-table format with frontmatter status: planning
|
||
// After planned-phase the body Status should become 'Ready to execute' and
|
||
// frontmatter status should advance accordingly.
|
||
const TABLE_STATUS_PLANNING_1257 = `---
|
||
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 |
|
||
`;
|
||
|
||
function make1257TempProject(stateContent) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1257-'));
|
||
const planningDir = path.join(dir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
// Minimal ROADMAP so phase resolution can proceed
|
||
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;
|
||
}
|
||
|
||
describe('#1257 — planned-phase and begin-phase pipe-table regressions', () => {
|
||
|
||
// ── Finding 1 ───────────────────────────────────────────────────────────────
|
||
|
||
test('Finding 1: planned-phase advances Configuration pipe-table Status cell to Ready to execute', () => {
|
||
// planned-phase should update the Configuration-section body | Status | … | cell.
|
||
// Smoking-gun: state.cts:2015 calls stateReplaceFieldIfTemplate on full content,
|
||
// so the frontmatter `status:` key shadows the body table cell and the cell is
|
||
// never updated. (updateCurrentPositionFields at line 2037 does correctly update
|
||
// the Current Position table cell — this test specifically targets the Configuration
|
||
// table cell, which has no pipe-table else-branch in planned-phase.)
|
||
const dir = make1257TempProject(TABLE_STATUS_PLANNING_1257);
|
||
try {
|
||
const result = runGsdTools(
|
||
['state', 'planned-phase', '--phase', '1', '--plans', '3'],
|
||
dir
|
||
);
|
||
assert.ok(result.success, `planned-phase failed: ${result.error || result.output}`);
|
||
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
|
||
// Extract the ## Configuration section (stops before ## Current Position)
|
||
// to avoid false-positive from the Current Position table (which IS updated
|
||
// by updateCurrentPositionFields).
|
||
const cfgMatch = after.match(/##\s*Configuration\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(cfgMatch, '## Configuration section must exist');
|
||
const cfgSection = cfgMatch[1];
|
||
|
||
// The Configuration section's pipe-table Status cell must be updated
|
||
assert.ok(
|
||
/\|\s*Status\s*\|\s*Ready to execute\s*\|/i.test(cfgSection),
|
||
`Configuration pipe-table Status cell must be 'Ready to execute' after planned-phase; got Configuration:\n${cfgSection}`
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('Finding 1: planned-phase advances frontmatter status to executing when body Status is pipe-table', () => {
|
||
// The frontmatter status must advance after planned-phase sets Status to 'Ready to execute'.
|
||
// (syncStateFrontmatter maps 'ready to execute' → 'executing'.)
|
||
const dir = make1257TempProject(TABLE_STATUS_PLANNING_1257);
|
||
try {
|
||
runGsdTools(
|
||
['state', 'planned-phase', '--phase', '1', '--plans', '3'],
|
||
dir
|
||
);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
|
||
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||
assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after planned-phase');
|
||
const fm = fmMatch[1];
|
||
// syncStateFrontmatter maps 'Ready to execute' → 'executing' in normalizeStateStatus
|
||
assert.ok(
|
||
/^status:\s*executing\s*$/m.test(fm),
|
||
`frontmatter status must be 'executing' after planned-phase on pipe-table STATUS; got frontmatter:\n${fm}`
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// ── Finding 2 ───────────────────────────────────────────────────────────────
|
||
|
||
test('Finding 2: begin-phase updates Current Position pipe-table Phase cell (not prepend inline)', () => {
|
||
// begin-phase must update the | Phase | … | cell in ## Current Position.
|
||
// Smoking-gun: state.cts:1833 checks `^Phase:` (plain-text pattern) which
|
||
// never matches a pipe-table row, so the else-branch at 1836 PREPENDS a new
|
||
// inline `Phase: N — EXECUTING` line to the section instead of updating the cell.
|
||
const dir = make1257TempProject(TABLE_STATUS_PLANNING_1257);
|
||
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');
|
||
|
||
// Extract ## Current Position section only
|
||
const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(cpMatch, '## Current Position section must exist');
|
||
const cpSection = cpMatch[1];
|
||
|
||
// The pipe-table Phase cell must be updated to reflect the executing phase
|
||
assert.ok(
|
||
/\|\s*Phase\s*\|[^|]*1[^|]*EXECUTING[^|]*\|/i.test(cpSection),
|
||
`Current Position pipe-table Phase cell must contain phase 1 EXECUTING; got Current Position:\n${cpSection}`
|
||
);
|
||
|
||
// Must NOT have a spurious prepended inline `Phase: …` text line
|
||
assert.ok(
|
||
!/^Phase:\s+\d/m.test(cpSection),
|
||
`Current Position must NOT have a spuriously prepended inline 'Phase: N' text line; got Current Position:\n${cpSection}`
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('Finding 2: begin-phase updates Current Position pipe-table Plan cell (not prepend inline)', () => {
|
||
// begin-phase must update the | Plan | … | cell in ## Current Position.
|
||
// Smoking-gun: state.cts:1841 checks `^Plan:` which never matches a pipe-table row,
|
||
// so the else-branch at 1843 replaces the (newly-prepended) inline Phase line with
|
||
// Phase\nPlan, neither touching the existing table | Plan | cell.
|
||
const dir = make1257TempProject(TABLE_STATUS_PLANNING_1257);
|
||
try {
|
||
runGsdTools(
|
||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
|
||
dir
|
||
);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
|
||
// Extract ## Current Position section only
|
||
const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||
assert.ok(cpMatch, '## Current Position section must exist');
|
||
const cpSection = cpMatch[1];
|
||
|
||
// The pipe-table Plan cell must be updated to '1 of 3'
|
||
assert.ok(
|
||
/\|\s*Plan\s*\|\s*1 of 3\s*\|/i.test(cpSection),
|
||
`Current Position pipe-table Plan cell must be '1 of 3'; got Current Position:\n${cpSection}`
|
||
);
|
||
|
||
// Must NOT have a spurious prepended inline `Plan: …` text line
|
||
assert.ok(
|
||
!/^Plan:\s+\d/m.test(cpSection),
|
||
`Current Position must NOT have a spuriously prepended inline 'Plan: N' text line; got Current Position:\n${cpSection}`
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// T6 section-splice characterization tests (ADR-1372 / #1398)
|
||
//
|
||
// Covers the migrated cmdState* write-ops across a matrix of fixture variants:
|
||
// inline (standard frontmatter + inline section text)
|
||
// trailing-blanks (sections with extra blank lines)
|
||
// CRLF (Windows line endings)
|
||
// no-frontmatter (bare body only)
|
||
// nested-acc (Accumulated Context with Session Notes subsection)
|
||
// no-current-pos (absent Current Position section)
|
||
// post-milestone (fresh milestone, prior progress=100%)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('T6 section-splice characterization — record-session', () => {
|
||
// Fixtures used across these tests
|
||
const STATE_WITH_SESSION = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'milestone_name: TestMilestone',
|
||
'status: executing',
|
||
"last_updated: '2026-01-01T00:00:00.000Z'",
|
||
"last_activity: '2026-01-01'",
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
||
'**Stopped at:** None',
|
||
'**Resume file:** None',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_SESSION_LABELS = [
|
||
'# Project State',
|
||
'',
|
||
'**Current focus:** Phase 2',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 2',
|
||
'Plan: 1 of 4',
|
||
'Status: Executing Phase 2',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: Chose PostgreSQL',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('record-session no-op: no session fields → recorded:false, STATE.md byte-unchanged', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_SESSION_LABELS);
|
||
const result = runGsdTools(['state', 'record-session'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, false, 'recorded must be false when no session fields exist');
|
||
// milestone_name must NOT be trampled (#952 no-op guard)
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.strictEqual(after, STATE_NO_SESSION_LABELS, 'STATE.md must be byte-unchanged on no-op');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('record-session --stopped-at updates Stopped at field in Session section', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION);
|
||
const result = runGsdTools(['state', 'record-session', '--stopped-at', '14.3'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded must be true when session fields found');
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('**Stopped at:** 14.3'), 'Stopped at field must be updated to 14.3');
|
||
// milestone_name must be preserved (not trampled)
|
||
assert.ok(after.includes('milestone_name: TestMilestone'), 'milestone_name must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('record-session --resume-file updates Resume file field in Session section', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION);
|
||
const result = runGsdTools(['state', 'record-session', '--resume-file', 'plan-3.md'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('**Resume file:** plan-3.md'), 'Resume file field must be updated to plan-3.md');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — add-decision', () => {
|
||
const STATE_INLINE = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'milestone_name: TestMilestone',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: Use Node.js for tooling',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None yet.',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_TRAILING_BLANKS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 1',
|
||
'',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: First decision',
|
||
'',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'- Bug in auth service',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_DECISIONS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('add-decision appends to existing Decisions Made section (inline fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE);
|
||
const result = runGsdTools(['state', 'add-decision', '--phase', '2', '--summary', 'Use Docker for builds'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- [Phase 2]: Use Docker for builds'), 'new decision entry must be present');
|
||
assert.ok(after.includes('- [Phase 1]: Use Node.js for tooling'), 'existing decision must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-decision appends to Decisions Made section with trailing blank lines (trailing-blanks fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS);
|
||
const result = runGsdTools(['state', 'add-decision', '--phase', '3', '--summary', 'Add monitoring'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- [Phase 3]: Add monitoring'), 'new decision must be present');
|
||
assert.ok(after.includes('- [Phase 1]: First decision'), 'original decision must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-decision creates Decisions section when absent (DWIM)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_DECISIONS);
|
||
const result = runGsdTools(['state', 'add-decision', '--phase', '1', '--summary', 'Use Node.js'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- [Phase 1]: Use Node.js'), 'decision must be present even when section was absent');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — add-blocker', () => {
|
||
const STATE_INLINE_WITH_BLOCKERS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: Use Node.js for tooling',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None yet.',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_CRLF = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n\r\n### Blockers\r\n\r\nNone.\r\n';
|
||
|
||
const STATE_NO_BLOCKERS_SECTION = [
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 2',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('add-blocker appends to existing Blockers section (inline fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_BLOCKERS);
|
||
const result = runGsdTools(['state', 'add-blocker', '--text', 'Flaky CI on Windows'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
assert.strictEqual(output.blocker, 'Flaky CI on Windows', 'blocker text must match');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Flaky CI on Windows'), 'blocker entry must be present');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-blocker appends to existing Blockers section (CRLF fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF);
|
||
const result = runGsdTools(['state', 'add-blocker', '--text', 'NFS mount issue'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('NFS mount issue'), 'blocker entry must be present in CRLF file');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-blocker creates Blockers section when absent (DWIM)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_BLOCKERS_SECTION);
|
||
const result = runGsdTools(['state', 'add-blocker', '--text', 'Build pipeline broken'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('Build pipeline broken'), 'blocker must be present even when section was absent');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — resolve-blocker', () => {
|
||
const STATE_WITH_BLOCKERS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: First decision',
|
||
'',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'- Bug in auth service',
|
||
'- Another blocker',
|
||
'',
|
||
'### Recently Completed',
|
||
'',
|
||
'- Phase 1 Plan 1',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('resolve-blocker removes target blocker, preserves others (trailing-blanks fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_BLOCKERS);
|
||
const result = runGsdTools(['state', 'resolve-blocker', '--text', 'Bug in auth service'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.resolved, true, 'resolved must be true');
|
||
assert.strictEqual(output.blocker, 'Bug in auth service', 'resolved blocker text must match');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(!after.includes('- Bug in auth service'), 'resolved blocker must be removed');
|
||
assert.ok(after.includes('Another blocker'), 'unrelated blocker must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — add-roadmap-evolution', () => {
|
||
const STATE_NESTED_ACC = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 3',
|
||
'Status: Executing Phase 3',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: Use TypeScript',
|
||
'- [Phase 2]: Use Jest',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'Some context text here.',
|
||
'',
|
||
'### Roadmap Evolution',
|
||
'',
|
||
'- Phase 1 added: Initial planning',
|
||
'- Phase 2 changed: Scope updated',
|
||
'',
|
||
'### Session Notes',
|
||
'',
|
||
'Some notes.',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
||
'**Stopped at:** None',
|
||
'**Resume file:** None',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_INLINE_WITH_ROAD_EVO = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Roadmap Evolution',
|
||
'',
|
||
'None yet.',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_ACC_SECTION = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_CRLF_ROAD = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Accumulated Context\r\n\r\n### Roadmap Evolution\r\n\r\n- Phase 1 added: Initial migration\r\n';
|
||
|
||
const STATE_POST_MILESTONE = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v2.0',
|
||
'milestone_name: NextMilestone',
|
||
'status: planning',
|
||
'progress:',
|
||
' total_phases: 4',
|
||
' completed_phases: 4',
|
||
' total_plans: 12',
|
||
' completed_plans: 12',
|
||
' percent: 100',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: Not started (defining requirements)',
|
||
'Plan: —',
|
||
'Status: Defining requirements',
|
||
'Last activity: 2026-01-15 — Milestone v2.0 started',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Roadmap Evolution',
|
||
'',
|
||
'- Phase 1 complete after Phase 1: Migration done',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('add-roadmap-evolution appends to existing Roadmap Evolution subsection (nested-acc fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NESTED_ACC);
|
||
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '4', '--action', 'added', '--note', 'New API endpoint'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
assert.ok(output.entry.includes('Phase 4 added'), 'entry must reference phase 4 added');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Phase 4 added: New API endpoint'), 'new entry must be present');
|
||
assert.ok(after.includes('- Phase 1 added: Initial planning'), 'existing entries must be preserved');
|
||
assert.ok(after.includes('- Phase 2 changed: Scope updated'), 'second existing entry must be preserved');
|
||
// Session Notes subsection must be preserved (not consumed by splice)
|
||
assert.ok(after.includes('### Session Notes'), 'Session Notes subsection must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-roadmap-evolution creates Roadmap Evolution subsection when absent but acc section present', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_ROAD_EVO);
|
||
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '3', '--action', 'changed', '--note', 'Scope updated significantly'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Phase 3 changed: Scope updated significantly'), 'new entry must be present');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-roadmap-evolution creates Accumulated Context and subsection when both absent (DWIM)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_ACC_SECTION);
|
||
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '1', '--action', 'added', '--note', 'Initial setup'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Phase 1 added: Initial setup'), 'new entry must be present');
|
||
assert.ok(after.includes('### Roadmap Evolution'), 'Roadmap Evolution subsection must be created');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-roadmap-evolution appends to Roadmap Evolution in CRLF file', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_ROAD);
|
||
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'changed', '--note', 'CRLF test case'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Phase 2 changed: CRLF test case'), 'new CRLF entry must be present');
|
||
assert.ok(after.includes('- Phase 1 added: Initial migration'), 'existing CRLF entry must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('add-roadmap-evolution appends to Roadmap Evolution in post-milestone STATE.md', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_POST_MILESTONE);
|
||
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'added', '--note', 'New phase inserted'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.added, true, 'added must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(after.includes('- Phase 2 added: New phase inserted'), 'new entry must be present');
|
||
assert.ok(after.includes('- Phase 1 complete after Phase 1: Migration done'), 'prior entry must be preserved');
|
||
// Frontmatter milestone_name must NOT be trampled
|
||
assert.ok(after.includes('milestone_name: NextMilestone'), 'milestone_name must be preserved');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — begin-phase', () => {
|
||
const STATE_INLINE_POS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'milestone_name: TestMilestone',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 1 (Setup)',
|
||
'Plan: 2 of 3',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'Last activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_FRONTMATTER_POS = [
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 2',
|
||
'Plan: 1 of 4',
|
||
'Status: Executing Phase 2',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_CURRENT_POS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: First decision',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('begin-phase updates Current Position and status (inline fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_POS);
|
||
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
// Phase line must reflect new phase
|
||
assert.ok(/Phase:\s+2/.test(after), 'Phase line must reference phase 2');
|
||
// Plan counter must reset to 1 of 4
|
||
assert.ok(/Plan:\s+1 of 4/.test(after), 'Plan line must be reset to 1 of 4');
|
||
// Frontmatter status must be executing
|
||
assert.ok(/^status:\s+executing/m.test(after), 'frontmatter status must be executing');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('begin-phase updates Current Position without frontmatter (no-frontmatter fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_FRONTMATTER_POS);
|
||
const result = runGsdTools(['state', 'begin-phase', '--phase', '3', '--plans', '2'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/Phase:\s+3/.test(after), 'Phase line must reference phase 3');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('begin-phase handles absent Current Position section gracefully', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_CURRENT_POS);
|
||
const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
// Expect phase, phase_name and plan_count in response even if fields weren't updated
|
||
assert.strictEqual(output.phase, '1', 'phase must be reported in response');
|
||
assert.strictEqual(output.plan_count, 3, 'plan_count must be reported in response');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — complete-phase', () => {
|
||
const STATE_INLINE_EXEC = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 1 (Setup)',
|
||
'Plan: 2 of 3',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_TRAILING_BLANKS_EXEC = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 1',
|
||
'',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: First decision',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_CRLF_EXEC = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n';
|
||
|
||
test('complete-phase marks current phase complete and sets frontmatter status (inline fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_EXEC);
|
||
const result = runGsdTools(['state', 'complete-phase'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
assert.ok(output.updated.includes('Status'), 'Status must be in updated list');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed');
|
||
assert.ok(/Status:\s+Phase\s+1\s+complete/i.test(after), 'body Status field must reflect phase complete');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('complete-phase works correctly with trailing blank lines in Current Position (trailing-blanks fixture)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS_EXEC);
|
||
const result = runGsdTools(['state', 'complete-phase'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('complete-phase works correctly on CRLF STATE.md', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_EXEC);
|
||
const result = runGsdTools(['state', 'complete-phase', '--phase', '2'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(output.updated), 'updated must be an array');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed after CRLF complete-phase');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('T6 section-splice characterization — milestone-switch', () => {
|
||
const STATE_INLINE_MS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'milestone_name: OldMilestone',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 1 (Setup)',
|
||
'Plan: 1 of 3',
|
||
'Status: Executing Phase 1',
|
||
'',
|
||
].join('\n');
|
||
|
||
const STATE_NO_POSITION_MS = [
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'milestone_name: OldMilestone',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'## Decisions Made',
|
||
'',
|
||
'- [Phase 1]: First decision',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('milestone-switch updates milestone and milestone_name in frontmatter (position present)', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_MS);
|
||
const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'NextMilestone'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.switched, true, 'switched must be true');
|
||
assert.strictEqual(output.version, 'v2.0', 'version must be v2.0');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/^milestone:\s+v2\.0/m.test(after), 'frontmatter milestone must be v2.0');
|
||
assert.ok(/^milestone_name:\s+NextMilestone/m.test(after), 'frontmatter milestone_name must be NextMilestone');
|
||
assert.ok(/^status:\s+planning/m.test(after), 'frontmatter status must be reset to planning on milestone switch');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
|
||
test('milestone-switch updates frontmatter when Current Position is absent', () => {
|
||
const d = createTempProject();
|
||
try {
|
||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_POSITION_MS);
|
||
const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v3.0'], d);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.switched, true, 'switched must be true');
|
||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.ok(/^milestone:\s+v3\.0/m.test(after), 'frontmatter milestone must be v3.0');
|
||
} finally {
|
||
cleanup(d);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-1761-state-sync-wrong-progress.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-1761-state-sync-wrong-progress (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
// Regression test for issue #1761 — `state sync` silently writes wrong progress
|
||
// when ROADMAP lacks versioned milestone headings.
|
||
//
|
||
// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is
|
||
// true (the current milestone cannot be bounded to a versioned phase set), the
|
||
// sync transition leaves Progress untouched (percent=null) rather than silently
|
||
// computing/writing values off a fallback milestone.
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
function buildStateWithProgress({ percent = 50 } = {}) {
|
||
const barWidth = 10;
|
||
const filled = Math.round((percent / 100) * barWidth);
|
||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Test',
|
||
'current_phase: "3"',
|
||
'status: executing',
|
||
'progress:',
|
||
' total_phases: 10',
|
||
' completed_phases: 5',
|
||
` percent: ${percent}`,
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'**Current Phase:** 3',
|
||
'**Total Plans in Phase:** 4',
|
||
'**Current Plan:** 2',
|
||
'**Status:** Executing Phase 3',
|
||
'**Last Activity:** 2026-06-20',
|
||
`**Progress:** [${bar}] ${percent}%`,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger.
|
||
function buildUnversionedRoadmap(numPhases) {
|
||
const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', ''];
|
||
for (let i = 1; i <= numPhases; i++) {
|
||
lines.push(`### Phase ${i}: phase-${i}`);
|
||
lines.push('');
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function readBodyProgress(statePath) {
|
||
const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/);
|
||
return m ? m[1].trim() : null;
|
||
}
|
||
|
||
describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => {
|
||
let tmpDir;
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 }));
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10));
|
||
|
||
// Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes,
|
||
// it would write ~20%, clobbering the curated 50% (#1761).
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
for (let i = 1; i <= 2; i++) {
|
||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n');
|
||
}
|
||
|
||
const before = readBodyProgress(statePath);
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||
const after = readBodyProgress(statePath);
|
||
|
||
assert.strictEqual(after, before,
|
||
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
|
||
});
|
||
});
|
||
|
||
// #1761 read-path: the ADR-1769 Phase 7 fix (#1794) closed the `state sync`
|
||
// WRITE path, but `state json` (the READ path) rebuilds progress via
|
||
// buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings
|
||
// across the WHOLE document when extractCurrentMilestone can't bound the
|
||
// asserted milestone. Result: state json reported a conflated total_phases
|
||
// (sum of sibling milestones) + a derived percent, contradicting the sync
|
||
// guard. This block mirrors the write-path guard on the read path.
|
||
describe('#1761 read-path: state json does not conflate progress when milestone is unbounded', () => {
|
||
let tmpDir;
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('state json omits percent and does NOT report the conflated whole-doc total_phases', () => {
|
||
// Repro from the issue: STATE.md asserts milestone: v2.0; ROADMAP has two
|
||
// UNVERSIONED sibling milestones (4 + 4 phases) — neither matches v2.0, so
|
||
// the milestone is unbounded. One summarized phase dir on disk.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v2.0',
|
||
'milestone_name: Second',
|
||
'current_phase: "2"',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'**Current Phase:** 2',
|
||
'**Status:** Executing Phase 2',
|
||
'',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||
'# ROADMAP',
|
||
'## Milestone 1: First Milestone',
|
||
'### Phase 1: a',
|
||
'### Phase 2: b',
|
||
'### Phase 3: c',
|
||
'### Phase 4: d',
|
||
'## Milestone 2: Second Milestone',
|
||
'### Phase 5: e',
|
||
'### Phase 6: f',
|
||
'### Phase 7: g',
|
||
'### Phase 8: h',
|
||
'',
|
||
].join('\n'));
|
||
// One summarized phase dir on disk.
|
||
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
|
||
fs.mkdirSync(dir01, { recursive: true });
|
||
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
|
||
|
||
const result = runGsdTools('state json --raw', tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
|
||
// BEFORE the fix this printed progress.total_phases: 8 (4+4 sibling
|
||
// milestones) and percent: 13 — exactly the conflated read-path the sync
|
||
// guard was added to prevent.
|
||
assert.ok(
|
||
out.progress === undefined || out.progress.percent === undefined,
|
||
`state json must omit percent when the milestone is unbounded; got progress=${JSON.stringify(out.progress)}`,
|
||
);
|
||
assert.ok(
|
||
!(out.progress && out.progress.total_phases === 8),
|
||
`state json must NOT report the conflated whole-doc total_phases (8 = 4+4 sibling milestones); got total_phases=${out.progress && out.progress.total_phases}`,
|
||
);
|
||
});
|
||
|
||
test('state json still reports percent + total_phases when the milestone IS bounded (versioned ROADMAP)', () => {
|
||
// Control: a versioned ROADMAP heading matching the asserted milestone
|
||
// keeps the read path unchanged — the guard only fires when unbounded.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: First',
|
||
'current_phase: "1"',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'**Current Phase:** 1',
|
||
'**Status:** Executing Phase 1',
|
||
'',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||
'# ROADMAP',
|
||
'## Milestone 1: First Milestone v1.0',
|
||
'### Phase 1: a',
|
||
'### Phase 2: b',
|
||
'',
|
||
].join('\n'));
|
||
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
|
||
fs.mkdirSync(dir01, { recursive: true });
|
||
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
|
||
|
||
const result = runGsdTools('state json --raw', tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
assert.ok(
|
||
out.progress && typeof out.progress.percent === 'number',
|
||
`state json must report a numeric percent when the milestone is bounded; got progress=${JSON.stringify(out.progress)}`,
|
||
);
|
||
assert.strictEqual(
|
||
out.progress.total_phases,
|
||
2,
|
||
'bounded read path must report the versioned milestone phase count (2)',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2630-state-frontmatter-milestone-switch.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2630-state-frontmatter-milestone-switch (consolidation epic #1969 B2 #1971)", () => {
|
||
/**
|
||
* GSD Tools Tests — Bug #2630
|
||
*
|
||
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
|
||
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
|
||
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
|
||
* workflow rewrote the body but left the frontmatter pointing at the previous
|
||
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
|
||
* reported the stale milestone.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
const STALE_STATE = `---
|
||
gsd_state_version: 1.0
|
||
milestone: v1.0
|
||
milestone_name: Foundation
|
||
status: completed
|
||
progress:
|
||
total_phases: 5
|
||
completed_phases: 5
|
||
total_plans: 12
|
||
completed_plans: 12
|
||
percent: 100
|
||
---
|
||
|
||
# Project State
|
||
|
||
## Current Position
|
||
|
||
Phase: 5 (Foundation) — COMPLETED
|
||
Plan: 3 of 3
|
||
Status: v1.0 milestone complete
|
||
Last activity: 2026-04-20 -- v1.0 shipped
|
||
|
||
## Accumulated Context
|
||
|
||
### Decisions
|
||
|
||
- [Phase 1]: Use Node 20
|
||
`;
|
||
|
||
describe('state milestone-switch (#2630)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
STALE_STATE,
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
'{}',
|
||
'utf-8',
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
|
||
const result = runGsdTools(
|
||
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
|
||
tmpDir,
|
||
);
|
||
assert.equal(result.success, true, result.error || result.output);
|
||
|
||
const after = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'utf-8',
|
||
);
|
||
|
||
// Frontmatter reflects the NEW milestone — the core of bug #2630.
|
||
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
|
||
assert.match(
|
||
after,
|
||
/^milestone_name:\s*Notifications\s*$/m,
|
||
'frontmatter milestone_name not switched',
|
||
);
|
||
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
|
||
// Progress counters reset to zero.
|
||
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
|
||
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
|
||
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
|
||
|
||
// Body Current Position reset to the new-milestone template.
|
||
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
|
||
assert.match(
|
||
after,
|
||
/Phase:\s*Not started \(defining requirements\)/,
|
||
'body Phase not reset',
|
||
);
|
||
|
||
// Accumulated Context is preserved.
|
||
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
|
||
});
|
||
|
||
test('rejects missing --milestone', () => {
|
||
const result = runGsdTools(
|
||
['state', 'milestone-switch', '--name', 'Something'],
|
||
tmpDir,
|
||
);
|
||
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
|
||
const combined = (result.output || '') + (result.error || '');
|
||
assert.match(combined, /milestone required/i);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3286-state-write-routing.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3286-state-write-routing (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
// Regression tests for issue #3286 — three bugs in state.cjs:
|
||
//
|
||
// Bug A: cmdStateRecordMetric / cmdStateAddDecision return { recorded: false }
|
||
// with exit code 0 when their target section is absent. gsd-executor treats
|
||
// exit 0 as success, silently losing metrics/decisions across an entire phase.
|
||
// Fix: auto-create the missing section (Bug B subsumes A — silent no-op
|
||
// disappears). When auto-created, JSON must include created: true.
|
||
//
|
||
// Bug B: A fresh STATE.md without ## Performance Metrics or ## Decisions causes
|
||
// both verbs to silently no-op. DWIM: auto-create the canonical section scaffold
|
||
// and then write the row/entry, matching state begin-phase / advance-plan behavior.
|
||
//
|
||
// Bug C: state record-metric and add-decision must honor --ws <name>, routing
|
||
// writes to .planning/workstreams/<name>/STATE.md instead of root STATE.md.
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Fixtures
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Build a minimal STATE.md with all canonical sections */
|
||
function buildFullStateMd() {
|
||
return [
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 1',
|
||
'Current Phase Name: bootstrap',
|
||
'Total Plans in Phase: 3',
|
||
'Current Plan: 1',
|
||
'Status: Executing',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'## Performance Metrics',
|
||
'',
|
||
'| Phase | Plan | Duration | Notes |',
|
||
'|-------|------|----------|-------|',
|
||
'',
|
||
'## Decisions',
|
||
'',
|
||
'None yet.',
|
||
'',
|
||
'### Blockers',
|
||
'',
|
||
'None.',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/** Build a STATE.md WITHOUT Performance Metrics or Decisions sections */
|
||
function buildBareboneStateMd() {
|
||
return [
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 1',
|
||
'Current Phase Name: bootstrap',
|
||
'Total Plans in Phase: 3',
|
||
'Current Plan: 1',
|
||
'Status: Executing',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Group B: auto-create missing sections (DWIM)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3286 Bug B: record-metric auto-creates ## Performance Metrics when missing', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('record-metric succeeds when ## Performance Metrics is absent', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `record-metric must succeed (exit 0), got: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.recorded, true, `recorded must be true, got: ${JSON.stringify(parsed)}`);
|
||
});
|
||
|
||
test('record-metric with created:true when section was auto-created', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.created, true, `JSON must include created:true when section was auto-created`);
|
||
});
|
||
|
||
test('record-metric appends row into auto-created section — verifiable via state snapshot', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '2', '--duration', '30min', '--tasks', '5'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
|
||
|
||
// Verify the metric appeared in the file by calling state get to read the section
|
||
const getResult = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
||
assert.ok(getResult.success, `state get must succeed, got: ${getResult.error}`);
|
||
|
||
// Parse JSON to check structural content (no .includes on raw file)
|
||
const sectionContent = JSON.parse(getResult.output);
|
||
const sectionText = sectionContent['Performance Metrics'] || '';
|
||
// Must contain a row referencing Phase 1 P2
|
||
assert.ok(
|
||
sectionText.includes('Phase 1 P2') || sectionText.includes('| Phase 1 P2'),
|
||
`Performance Metrics section must contain the appended row. Got section: ${sectionText}`,
|
||
);
|
||
});
|
||
|
||
test('record-metric on state with existing section still works (no regression)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildFullStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '2', '--plan', '1', '--duration', '1h'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `record-metric must succeed on existing section, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
||
// created should be absent or false when section already existed
|
||
assert.ok(!parsed.created, `created must be absent/false when section existed`);
|
||
});
|
||
});
|
||
|
||
describe('#3286 Bug B: add-decision auto-creates ## Decisions when missing', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('add-decision succeeds when ## Decisions is absent', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '1', '--summary', 'Use TypeScript for type safety'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `add-decision must succeed (exit 0), got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.added, true, `added must be true, got: ${JSON.stringify(parsed)}`);
|
||
});
|
||
|
||
test('add-decision with created:true when section was auto-created', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '1', '--summary', 'Use Redis for caching'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.created, true, `JSON must include created:true when Decisions section auto-created`);
|
||
});
|
||
|
||
test('add-decision appended entry is visible in state get', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const summary = 'Adopt PostgreSQL over MySQL for JSONB support';
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '2', '--summary', summary],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
|
||
|
||
// Verify via state get (structured), not raw file grep
|
||
const getResult = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
|
||
assert.ok(getResult.success, `state get Decisions must succeed, got: ${getResult.error}`);
|
||
|
||
const sectionContent = JSON.parse(getResult.output);
|
||
const sectionText = sectionContent['Decisions'] || '';
|
||
assert.ok(
|
||
sectionText.includes(summary),
|
||
`Decisions section must contain the appended decision. Got: ${sectionText}`,
|
||
);
|
||
});
|
||
|
||
test('add-decision on state with existing Decisions section works (no regression)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildFullStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '1', '--summary', 'Use monorepo layout'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `add-decision must succeed on existing section, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.added, true, `added must be true`);
|
||
assert.ok(!parsed.created, `created must be absent/false when section already existed`);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Group A: exit code contract (covered by Bug B fix — no silent no-op)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3286 Bug A: record-metric / add-decision never silently no-op', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('record-metric always has recorded:true (never silent false)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Minimal state — no Performance Metrics section
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '20min'],
|
||
tmpDir,
|
||
);
|
||
|
||
// Must exit 0 AND recorded must be true (auto-created or found)
|
||
assert.ok(result.success, `record-metric must exit 0, got stderr: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(
|
||
parsed.recorded,
|
||
true,
|
||
`recorded must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
|
||
);
|
||
});
|
||
|
||
test('add-decision always has added:true (never silent false)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '1', '--summary', 'Prefer composition over inheritance'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `add-decision must exit 0, got stderr: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(
|
||
parsed.added,
|
||
true,
|
||
`added must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Group C: workstream routing — writes go to workstream STATE.md, not root
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3286 Bug C: record-metric / add-decision honor --ws routing', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
|
||
// Create root STATE.md with Performance Metrics + Decisions sections
|
||
const rootStatePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(rootStatePath, buildFullStateMd());
|
||
|
||
// Create workstream foo with its own STATE.md (full sections)
|
||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'foo');
|
||
fs.mkdirSync(wsDir, { recursive: true });
|
||
const wsStatePath = path.join(wsDir, 'STATE.md');
|
||
fs.writeFileSync(wsStatePath, buildFullStateMd());
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('record-metric --ws foo writes to workstream STATE.md, not root', () => {
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '10min', '--ws', 'foo'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `record-metric --ws foo must succeed, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
||
|
||
// Workstream STATE.md should have the row; root STATE.md should NOT
|
||
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
||
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
|
||
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
|
||
assert.ok(
|
||
!rootContent.includes('Phase 1 P1'),
|
||
`Root STATE.md must NOT have the metric row. Got: ${rootContent}`,
|
||
);
|
||
|
||
const wsGet = runGsdTools(['state', 'get', 'Performance Metrics', '--ws', 'foo'], tmpDir);
|
||
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
|
||
const wsContent = JSON.parse(wsGet.output)['Performance Metrics'] || '';
|
||
assert.ok(
|
||
wsContent.includes('Phase 1 P1'),
|
||
`Workstream foo STATE.md must have the metric row. Got: ${wsContent}`,
|
||
);
|
||
});
|
||
|
||
test('add-decision --ws foo writes to workstream STATE.md, not root', () => {
|
||
const summary = 'Adopt event-sourcing for audit trail';
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '1', '--summary', summary, '--ws', 'foo'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `add-decision --ws foo must succeed, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.added, true, `added must be true`);
|
||
|
||
// Root STATE.md must NOT have the decision
|
||
const rootGet = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
|
||
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
|
||
const rootContent = JSON.parse(rootGet.output)['Decisions'] || '';
|
||
assert.ok(
|
||
!rootContent.includes(summary),
|
||
`Root STATE.md must NOT have the decision. Got: ${rootContent}`,
|
||
);
|
||
|
||
// Workstream STATE.md must have the decision
|
||
const wsGet = runGsdTools(['state', 'get', 'Decisions', '--ws', 'foo'], tmpDir);
|
||
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
|
||
const wsContent = JSON.parse(wsGet.output)['Decisions'] || '';
|
||
assert.ok(
|
||
wsContent.includes(summary),
|
||
`Workstream foo STATE.md must have the decision. Got: ${wsContent}`,
|
||
);
|
||
});
|
||
|
||
test('record-metric --ws foo auto-creates section in workstream STATE.md when missing', () => {
|
||
// Create a workstream without Performance Metrics section
|
||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'bar');
|
||
fs.mkdirSync(wsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(wsDir, 'STATE.md'), buildBareboneStateMd());
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '5min', '--ws', 'bar'],
|
||
tmpDir,
|
||
);
|
||
|
||
assert.ok(result.success, `record-metric --ws bar must succeed, got: ${result.error}`);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
||
assert.strictEqual(parsed.created, true, `created must be true when section auto-created in workstream`);
|
||
|
||
// Root STATE.md must remain untouched
|
||
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
||
assert.ok(rootGet.success);
|
||
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
|
||
assert.ok(
|
||
!rootContent.includes('Phase 1 P1'),
|
||
`Root STATE.md must not be written when --ws bar is used`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3454-state-dollar-backreference-growth.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3454-state-dollar-backreference-growth (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||
|
||
function seedState(tmpDir, planLine = '1 of 2') {
|
||
const state = `# Project State
|
||
|
||
**Status:** executing
|
||
**Current Phase:** 1
|
||
|
||
## Current Position
|
||
Phase: 1 of 1
|
||
Plan: ${planLine}
|
||
Status: Ready
|
||
Last activity: 2026-01-01
|
||
Budget: $2,500 max test
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01
|
||
`;
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), state, 'utf8');
|
||
}
|
||
|
||
function parseStateFile(tmpDir) {
|
||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
||
const sections = {};
|
||
const keyCountsBySection = {};
|
||
let currentSection = '__root__';
|
||
sections[currentSection] = {};
|
||
keyCountsBySection[currentSection] = {};
|
||
|
||
for (const rawLine of content.split(/\r?\n/u)) {
|
||
const headingMatch = /^##\s+(.+)$/u.exec(rawLine);
|
||
if (headingMatch) {
|
||
currentSection = headingMatch[1].trim();
|
||
sections[currentSection] = sections[currentSection] || {};
|
||
keyCountsBySection[currentSection] = keyCountsBySection[currentSection] || {};
|
||
continue;
|
||
}
|
||
|
||
const trimmed = rawLine.trim();
|
||
if (!trimmed) continue;
|
||
|
||
const boldFieldMatch = /^\*\*([^*]+)\*\*:\s*(.*)$/u.exec(trimmed);
|
||
if (boldFieldMatch) {
|
||
const key = boldFieldMatch[1].trim();
|
||
const value = boldFieldMatch[2].trim();
|
||
sections[currentSection][key] = value;
|
||
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
|
||
continue;
|
||
}
|
||
|
||
const colonIndex = trimmed.indexOf(':');
|
||
if (colonIndex <= 0) continue;
|
||
|
||
const key = trimmed.slice(0, colonIndex).trim();
|
||
const value = trimmed.slice(colonIndex + 1).trim();
|
||
sections[currentSection][key] = value;
|
||
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
|
||
}
|
||
|
||
return { content, sections, keyCountsBySection };
|
||
}
|
||
|
||
describe('bug #3454: state mutation must preserve literal $N amounts', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('bug-3454-');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state advance-plan keeps Current Position dollar amount literal', () => {
|
||
seedState(tmpDir, '1 of 20');
|
||
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||
assert.equal(result.success, true, `state advance-plan failed: ${result.error || result.output}`);
|
||
|
||
const parsed = parseStateFile(tmpDir);
|
||
const currentPosition = parsed.sections['Current Position'] || {};
|
||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
||
});
|
||
|
||
test('state begin-phase keeps Current Position dollar amount literal', () => {
|
||
seedState(tmpDir);
|
||
const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '2'], tmpDir);
|
||
assert.equal(result.success, true, `state begin-phase failed: ${result.error || result.output}`);
|
||
|
||
const parsed = parseStateFile(tmpDir);
|
||
const currentPosition = parsed.sections['Current Position'] || {};
|
||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
||
});
|
||
|
||
test('state complete-phase keeps Current Position dollar amount literal', () => {
|
||
seedState(tmpDir);
|
||
const result = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
|
||
assert.equal(result.success, true, `state complete-phase failed: ${result.error || result.output}`);
|
||
|
||
const parsed = parseStateFile(tmpDir);
|
||
const currentPosition = parsed.sections['Current Position'] || {};
|
||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
||
});
|
||
|
||
test('repeated state advance-plan stays size-bounded with dollar amounts', () => {
|
||
seedState(tmpDir, '1 of 20');
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
let stabilizedSize = null;
|
||
for (let i = 0; i < 8; i += 1) {
|
||
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||
assert.equal(result.success, true, `iteration ${i + 1} failed: ${result.error || result.output}`);
|
||
if (i === 0) stabilizedSize = fs.statSync(statePath).size;
|
||
}
|
||
|
||
const endSize = fs.statSync(statePath).size;
|
||
const growth = endSize / stabilizedSize;
|
||
assert.ok(growth <= 1.5, `expected <=1.5x growth after first write, got ${growth.toFixed(2)}x (${stabilizedSize} -> ${endSize})`);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3489-complete-phase-idempotent.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3489-complete-phase-idempotent (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
|
||
// allow-test-rule: source-text-is-the-product (see #3489)
|
||
// State.md is the deployed artifact; asserting on its literal text content
|
||
// tests the deployed contract.
|
||
|
||
/**
|
||
* Regression test for #3489
|
||
*
|
||
* `gsd state complete-phase --phase <N>` was non-idempotent. Re-invoking it
|
||
* on a phase already marked complete in STATE.md silently rolled STATE.md
|
||
* back to that phase's moment-of-completion — overwriting Status, Last
|
||
* Activity, Current Position and the body Status/Phase with stale values
|
||
* derived from the just-completed phase.
|
||
*
|
||
* Expected: when the target phase is already marked complete (and STATE.md
|
||
* has clearly advanced past it — e.g. a later phase is now in progress or
|
||
* inserted), `complete-phase` must be a no-op. No STATE.md write at all.
|
||
*/
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||
|
||
describe('bug #3489: state complete-phase must be idempotent', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('bug-3489-');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('re-running complete-phase on an already-complete phase does not roll STATE.md back', () => {
|
||
// STATE.md as it would appear AFTER phase 02.2 was legitimately completed
|
||
// AND a follow-up Phase 02.2.1 has since been inserted as in-progress.
|
||
// Re-invoking `state complete-phase --phase 02.2` from a downstream tool
|
||
// (e.g. a re-run of /gsd-execute-phase) must NOT regress this content.
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** in-progress',
|
||
'**Current Phase:** 02.2.1',
|
||
'**Last Activity:** 2026-05-13',
|
||
'**Last Activity Description:** Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 02.2.1 — Not planned yet',
|
||
'Status: Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||
'Last activity: 2026-05-13 -- Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||
'',
|
||
].join('\n');
|
||
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
||
const before = fs.readFileSync(statePath, 'utf8');
|
||
|
||
const result = runGsdTools(['state', 'complete-phase', '--phase', '02.2'], tmpDir);
|
||
assert.ok(result.success, `command should not error, got: ${result.error || result.output}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf8');
|
||
|
||
// Hard assertion: file is byte-identical to its pre-call snapshot.
|
||
assert.equal(
|
||
after,
|
||
before,
|
||
`STATE.md must not be rewritten when phase is already complete.\n\n--- before ---\n${before}\n--- after ---\n${after}`,
|
||
);
|
||
|
||
// Output should advertise the no-op so downstream consumers can detect it.
|
||
let payload = null;
|
||
try { payload = JSON.parse(result.output); } catch (_) { /* ignore */ }
|
||
assert.ok(payload && typeof payload === 'object', `expected JSON payload, got: ${result.output}`);
|
||
assert.deepEqual(payload.updated, [], `expected empty updated list, got: ${JSON.stringify(payload.updated)}`);
|
||
assert.equal(payload.phase, '02.2');
|
||
assert.equal(payload.idempotent, true, `expected idempotent:true flag, got: ${JSON.stringify(payload)}`);
|
||
});
|
||
|
||
test('completing the currently in-progress phase still works normally (no false-positive idempotency)', () => {
|
||
// Sanity check: the guard must not fire on the legitimate first completion.
|
||
const stateMd = [
|
||
'---',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Status:** in-progress',
|
||
'**Current Phase:** 03',
|
||
'**Last Activity:** 2026-05-13',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 03',
|
||
'Status: Phase 03 executing',
|
||
'',
|
||
].join('\n');
|
||
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
||
|
||
const result = runGsdTools(['state', 'complete-phase', '--phase', '03'], tmpDir);
|
||
assert.ok(result.success, `command failed: ${result.error || result.output}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf8');
|
||
assert.ok(
|
||
after.includes('**Status:** Phase 03 complete'),
|
||
`expected Status updated to "Phase 03 complete", got:\n${after}`,
|
||
);
|
||
|
||
const payload = JSON.parse(result.output);
|
||
assert.notEqual(payload.idempotent, true, 'first completion must not be flagged idempotent');
|
||
assert.ok(Array.isArray(payload.updated) && payload.updated.length > 0, 'expected non-empty updated list');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-397-state-preserve-executor-authored.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-397-state-preserve-executor-authored (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep (see #397)
|
||
|
||
// Regression tests for bug #397.
|
||
//
|
||
// STATE.md fields edited by an executor (e.g. a hand-authored Resume File path,
|
||
// a custom Status value, or a custom Last Activity entry) were silently overwritten
|
||
// by the next call to record-session, advance-plan, or planned-phase because the
|
||
// handlers used unconditional stateReplaceField / stateReplaceFieldWithFallback
|
||
// calls, even when the option was not passed by the caller.
|
||
//
|
||
// Fix: introduce KNOWN_TEMPLATE_DEFAULTS (a per-field table of string values that
|
||
// are safe to replace because they came from a template) and
|
||
// stateReplaceFieldIfTemplate (a helper that only replaces when the current value
|
||
// is a template default or absent). Handlers must consult this table rather than
|
||
// writing unconditionally.
|
||
//
|
||
// The 7 baseline cases verified here:
|
||
//
|
||
// 1. record-session WITHOUT --resume-file when Resume File is executor-authored
|
||
// → preserved (must NOT be replaced with 'None')
|
||
// 2. record-session WITHOUT --resume-file when Resume File is 'None'
|
||
// → remains 'None' (template-default → template-default is fine)
|
||
// 3. record-session WITH --resume-file → explicit caller value wins (always)
|
||
// 4. advance-plan phase-complete when Status is executor-authored → preserved
|
||
// 5. advance-plan phase-complete when Status is a known default → replaced
|
||
// 6. advance-plan advance when Last Activity is executor-authored → preserved
|
||
// 7. updateCurrentPositionFields with executor-authored Current Position values
|
||
// → preserved
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const TOOLS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function makeTempPlanning(stateContent) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-397-'));
|
||
const planningDir = path.join(dir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
|
||
return dir;
|
||
}
|
||
|
||
function readState(dir) {
|
||
return fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
}
|
||
|
||
function runGsdState(args, cwd) {
|
||
const { execFileSync } = require('child_process');
|
||
const env = {
|
||
...process.env,
|
||
GSD_SESSION_KEY: '',
|
||
CODEX_THREAD_ID: '',
|
||
CLAUDE_SESSION_ID: '',
|
||
CLAUDE_CODE_SSE_PORT: '',
|
||
OPENCODE_SESSION_ID: '',
|
||
};
|
||
try {
|
||
execFileSync(process.execPath, [TOOLS_PATH, 'state', ...args], {
|
||
cwd,
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
env,
|
||
});
|
||
return { success: true };
|
||
} catch (err) {
|
||
return { success: false, error: err.stderr?.toString().trim() || err.message };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixtures
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Case 1: Resume File is executor-authored (not a template default)
|
||
const STATE_EXECUTOR_RESUME_FILE = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 2
|
||
Total Plans in Phase: 4
|
||
Current Plan: 1
|
||
Status: Ready to execute
|
||
|
||
## Current Position
|
||
Phase: 2
|
||
Plan: 1 of 4
|
||
Status: Ready to execute
|
||
Last activity: 2026-01-01
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: /home/user/my-custom-context.md
|
||
Stopped At: Phase 2 Plan 1 complete
|
||
`;
|
||
|
||
// Case 2: Resume File is 'None' (the known template default)
|
||
const STATE_DEFAULT_RESUME_FILE = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 2
|
||
Total Plans in Phase: 4
|
||
Current Plan: 1
|
||
Status: Ready to execute
|
||
|
||
## Current Position
|
||
Phase: 2
|
||
Plan: 1 of 4
|
||
Status: Ready to execute
|
||
Last activity: 2026-01-01
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: None
|
||
Stopped At: Phase 2 Plan 1 complete
|
||
`;
|
||
|
||
// Cases 4 & 7: Status is executor-authored in both Configuration and Current Position.
|
||
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch of advance-plan.
|
||
const STATE_EXECUTOR_STATUS = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 3
|
||
Total Plans in Phase: 2
|
||
Current Plan: 2
|
||
Status: Awaiting QA sign-off before proceeding
|
||
|
||
## Current Position
|
||
Phase: 3
|
||
Plan: 2 of 2
|
||
Status: Awaiting QA sign-off before proceeding
|
||
Last activity: 2026-01-01
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: None
|
||
`;
|
||
|
||
// Case 5: Status IS a known template default ('Ready to execute').
|
||
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch.
|
||
const STATE_DEFAULT_STATUS = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 3
|
||
Total Plans in Phase: 2
|
||
Current Plan: 2
|
||
Status: Ready to execute
|
||
|
||
## Current Position
|
||
Phase: 3
|
||
Plan: 2 of 2
|
||
Status: Ready to execute
|
||
Last activity: 2026-01-01
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: None
|
||
`;
|
||
|
||
// Case 6: The only Last Activity field in the document is executor-authored
|
||
// (a narrative, not a bare ISO date). Current Plan=1, Total=3 → advance branch.
|
||
const STATE_EXECUTOR_LAST_ACTIVITY = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 2
|
||
Total Plans in Phase: 3
|
||
Current Plan: 1
|
||
Status: Ready to execute
|
||
Last Activity: Unblocked after infra fix — merged PR #88 manually
|
||
|
||
## Current Position
|
||
Phase: 2
|
||
Plan: 1 of 3
|
||
Status: Ready to execute
|
||
Last activity: 2026-01-01
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: None
|
||
`;
|
||
|
||
// Case 7: Current Position has executor-authored Status and Last activity.
|
||
// Current Plan=2, Total=3 → advance branch.
|
||
const STATE_EXECUTOR_CURRENT_POSITION = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 4
|
||
Total Plans in Phase: 3
|
||
Current Plan: 2
|
||
Status: Ready to execute
|
||
|
||
## Current Position
|
||
Phase: 4
|
||
Plan: 2 of 3
|
||
Status: On hold — waiting for upstream dependency merge
|
||
Last activity: 2026-02-15 -- blocked by infra; resume after merge
|
||
|
||
## Session Continuity
|
||
Last session: 2026-01-01T00:00:00.000Z
|
||
Last Date: 2026-01-01T00:00:00.000Z
|
||
Resume File: None
|
||
`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('bug #397: executor-authored STATE.md fields must be preserved', () => {
|
||
|
||
// Case 1: record-session without --resume-file, Resume File is executor-authored
|
||
test('case 1: record-session without --resume-file preserves executor-authored Resume File', () => {
|
||
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
|
||
try {
|
||
const r = runGsdState(['record-session', '--stopped-at', 'Plan 1 complete'], dir);
|
||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
||
assert.strictEqual(
|
||
rfMatch[1].trim(),
|
||
'/home/user/my-custom-context.md',
|
||
`record-session overwrote executor-authored Resume File with '${rfMatch[1].trim()}'`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 2: record-session without --resume-file, Resume File is 'None' (template default)
|
||
test('case 2: record-session without --resume-file keeps "None" when it is already "None"', () => {
|
||
const dir = makeTempPlanning(STATE_DEFAULT_RESUME_FILE);
|
||
try {
|
||
const r = runGsdState(['record-session', '--stopped-at', 'Plan complete'], dir);
|
||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
||
assert.strictEqual(
|
||
rfMatch[1].trim(),
|
||
'None',
|
||
`Expected 'None' to remain when it was already 'None', got: ${rfMatch[1].trim()}`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 3: record-session WITH --resume-file — explicit value always wins
|
||
test('case 3: record-session with --resume-file sets the explicit value', () => {
|
||
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
|
||
try {
|
||
const r = runGsdState(['record-session', '--resume-file', '/tmp/new-resume.md'], dir);
|
||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
||
assert.strictEqual(
|
||
rfMatch[1].trim(),
|
||
'/tmp/new-resume.md',
|
||
`Expected explicit --resume-file value to be written, got: ${rfMatch[1].trim()}`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 4: advance-plan phase-complete, Status is executor-authored → preserved
|
||
test('case 4: advance-plan (phase-complete) preserves executor-authored Status', () => {
|
||
const dir = makeTempPlanning(STATE_EXECUTOR_STATUS);
|
||
try {
|
||
// Current Plan=2, Total=2 → phase-complete branch
|
||
const r = runGsdState(['advance-plan'], dir);
|
||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
// The Configuration-level Status must not be clobbered
|
||
const statusMatch = after.match(/^Status:\s*(.+)/m);
|
||
assert.ok(statusMatch, 'Status field not found after advance-plan');
|
||
assert.strictEqual(
|
||
statusMatch[1].trim(),
|
||
'Awaiting QA sign-off before proceeding',
|
||
`advance-plan overwrote executor-authored Status: got '${statusMatch[1].trim()}'`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 5: advance-plan phase-complete, Status is a known default → replaced
|
||
test('case 5: advance-plan (phase-complete) replaces known-default Status with phase-complete value', () => {
|
||
const dir = makeTempPlanning(STATE_DEFAULT_STATUS);
|
||
try {
|
||
// Current Plan=2, Total=2 → phase-complete branch
|
||
const r = runGsdState(['advance-plan'], dir);
|
||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
const statusMatch = after.match(/^Status:\s*(.+)/m);
|
||
assert.ok(statusMatch, 'Status field not found after advance-plan');
|
||
// 'Ready to execute' is a known default and should be replaced
|
||
assert.notStrictEqual(
|
||
statusMatch[1].trim(),
|
||
'Ready to execute',
|
||
`Status should have been updated from 'Ready to execute' after phase-complete, but was not`,
|
||
);
|
||
assert.ok(
|
||
statusMatch[1].includes('Phase complete') || statusMatch[1].includes('ready for verification'),
|
||
`Expected phase-complete Status text, got: '${statusMatch[1].trim()}'`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 6: advance-plan normal advance, top-level Last Activity is executor-authored → preserved
|
||
test('case 6: advance-plan (normal advance) preserves executor-authored Last Activity', () => {
|
||
const dir = makeTempPlanning(STATE_EXECUTOR_LAST_ACTIVITY);
|
||
try {
|
||
// Current Plan=1, Total=3 → advance branch
|
||
const r = runGsdState(['advance-plan'], dir);
|
||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
// The top-level Last Activity (in Configuration section) must be preserved
|
||
const laMatch = after.match(/^Last Activity:\s*(.+)/im);
|
||
assert.ok(laMatch, 'Last Activity field not found after advance-plan');
|
||
assert.strictEqual(
|
||
laMatch[1].trim(),
|
||
'Unblocked after infra fix — merged PR #88 manually',
|
||
`advance-plan overwrote executor-authored Last Activity: got '${laMatch[1].trim()}'`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// Case 7: advance-plan preserves executor-authored Status and Last activity in Current Position
|
||
test('case 7: advance-plan preserves executor-authored Current Position Status and Last activity', () => {
|
||
const dir = makeTempPlanning(STATE_EXECUTOR_CURRENT_POSITION);
|
||
try {
|
||
// Current Plan=2, Total=3 → advance branch
|
||
const r = runGsdState(['advance-plan'], dir);
|
||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
||
const after = readState(dir);
|
||
const posMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||
assert.ok(posMatch, 'Current Position section not found after advance-plan');
|
||
const posBody = posMatch[1];
|
||
const posStatusMatch = posBody.match(/^Status:\s*(.+)/m);
|
||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
||
assert.strictEqual(
|
||
posStatusMatch[1].trim(),
|
||
'On hold — waiting for upstream dependency merge',
|
||
`advance-plan overwrote executor-authored Current Position Status: got '${posStatusMatch[1].trim()}'`,
|
||
);
|
||
const posActivityMatch = posBody.match(/^Last activity:\s*(.+)/im);
|
||
assert.ok(posActivityMatch, 'Last activity field not found in Current Position section');
|
||
assert.ok(
|
||
posActivityMatch[1].includes('blocked by infra'),
|
||
`advance-plan overwrote executor-authored Current Position Last activity: got '${posActivityMatch[1].trim()}'`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-905-state-syncstatefrontmatter-preserve-scalars (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression guard for bug #905.
|
||
*
|
||
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
|
||
* frontmatter when the body-derived value was missing/unknown. The scalars
|
||
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
|
||
* silently stripped whenever `buildStateFrontmatter` could not extract them from
|
||
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
|
||
* annotations.
|
||
*
|
||
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
|
||
* that all four scalars survive a `writeStateMd` / `state sync` call when the
|
||
* body no longer carries the annotation but the existing frontmatter does.
|
||
*/
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Fixture builders
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
|
||
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
|
||
* annotations that `buildStateFrontmatter` uses to re-derive them.
|
||
*
|
||
* This is the exact scenario that triggered the bug: the body has already lost
|
||
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
|
||
* frontmatter still holds the ground-truth values. A subsequent `state sync`
|
||
* (or any `writeStateMd` call) must not strip them.
|
||
*/
|
||
function buildStateMdWithoutBodyAnnotations(opts) {
|
||
const {
|
||
currentPhase = 3,
|
||
currentPhaseName = 'Implementation',
|
||
currentPlan = 2,
|
||
progressPercent = 42,
|
||
} = opts || {};
|
||
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
`current_phase: ${currentPhase}`,
|
||
`current_phase_name: ${currentPhaseName}`,
|
||
`current_plan: ${currentPlan}`,
|
||
'status: executing',
|
||
'progress:',
|
||
` total_phases: 5`,
|
||
` completed_phases: 2`,
|
||
` total_plans: 10`,
|
||
` completed_plans: 4`,
|
||
` percent: ${progressPercent}`,
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
// Intentionally omitting "Current Phase:", "Current Phase Name:",
|
||
// "Current Plan:" body annotations to reproduce the bug scenario.
|
||
'Status: Executing',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Decisions',
|
||
'',
|
||
'- Use Node 22',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Tests
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(
|
||
fm.current_phase,
|
||
'3',
|
||
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
|
||
);
|
||
});
|
||
|
||
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(
|
||
fm.current_phase_name,
|
||
'Implementation',
|
||
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
|
||
);
|
||
});
|
||
|
||
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(
|
||
fm.current_plan,
|
||
'2',
|
||
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
|
||
);
|
||
});
|
||
|
||
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
|
||
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
|
||
// That path runs syncStateFrontmatter and then explicitly re-applies the
|
||
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
|
||
// progress values must survive even though the phases dir is empty.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
|
||
|
||
// Add body annotation for Last Activity so state update can find and replace it
|
||
const initial = fs.readFileSync(statePath, 'utf8');
|
||
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
|
||
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Last Activity', '2026-06-08'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
|
||
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
|
||
// → curated block survives via cmdStateJson read-path fallback.
|
||
assert.strictEqual(
|
||
fm.progress.completed_plans,
|
||
4,
|
||
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
|
||
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
|
||
);
|
||
});
|
||
|
||
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
|
||
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
|
||
// with resync:true), confirming the fix covers every write path.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
|
||
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Last Activity', '2026-06-08'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(
|
||
fm.current_phase,
|
||
'7',
|
||
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
|
||
);
|
||
});
|
||
|
||
test('body annotation beats existing frontmatter when both are present', () => {
|
||
// When the body DOES carry the annotation, the derived value wins — we must
|
||
// not accidentally lock stale frontmatter in place.
|
||
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
|
||
// which rebuilds from the body and would return body-derived values regardless
|
||
// of what syncStateFrontmatter wrote to disk).
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Frontmatter says phase 3; body says phase 5. Body should win.
|
||
fs.writeFileSync(statePath, [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'current_phase: 3',
|
||
'current_phase_name: Old Phase',
|
||
'current_plan: 1',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 5',
|
||
'Current Phase Name: New Phase',
|
||
'Current Plan: 2',
|
||
'Status: Executing',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n'));
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
// Assert on raw file: body-derived values must be written to frontmatter,
|
||
// not the stale existing values. This guards against a fallback that locks
|
||
// in stale data even when buildStateFrontmatter successfully derived values.
|
||
const writtenContent = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(writtenContent);
|
||
assert.strictEqual(
|
||
rawFm.current_phase,
|
||
'5',
|
||
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
|
||
);
|
||
assert.strictEqual(
|
||
rawFm.current_phase_name,
|
||
'New Phase',
|
||
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
|
||
);
|
||
assert.strictEqual(
|
||
rawFm.current_plan,
|
||
'2',
|
||
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
|
||
);
|
||
});
|
||
|
||
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
|
||
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
|
||
// Without a phases dir, buildStateFrontmatter returns no progress block at all
|
||
// (the existsSync guard at line ~927 short-circuits the disk scan). The
|
||
// existing frontmatter's progress must then survive the writeStateMd call.
|
||
// Use createTempDir (no phases dir) and set up .planning/ manually.
|
||
const dir = createTempDir('gsd-905-nophasesdir-');
|
||
try {
|
||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||
const statePath = path.join(dir, '.planning', 'STATE.md');
|
||
|
||
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
|
||
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
|
||
fs.writeFileSync(statePath, [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'current_phase: 2',
|
||
'status: executing',
|
||
'progress:',
|
||
' total_phases: 4',
|
||
' completed_phases: 1',
|
||
' total_plans: 8',
|
||
' completed_plans: 3',
|
||
' percent: 38',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 2',
|
||
'Status: Executing',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n'));
|
||
|
||
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
|
||
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
|
||
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
|
||
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Last Activity', '2026-06-08'],
|
||
dir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
// Assert on the raw frontmatter file — cmdStateJson would apply
|
||
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
|
||
// The progress block must be present in the written frontmatter.
|
||
// parseFrontmatter returns flat keys, so check the presence indicator.
|
||
assert.ok(
|
||
written.includes('progress:'),
|
||
'progress block must be preserved in raw frontmatter when disk has no phases dir',
|
||
);
|
||
// percent: 38 should survive (no disk scan to overwrite it)
|
||
assert.ok(
|
||
written.includes('percent: 38'),
|
||
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #1230 regression suite
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a STATE.md where:
|
||
* - frontmatter has an explicit status (e.g. 'completed') and optional stopped_at
|
||
* - body has a Status: field that is STALE relative to the frontmatter
|
||
* (e.g. "Verifying Phase 3" would derive 'verifying')
|
||
* A subsequent incidental write must NOT revert the hand-set frontmatter status.
|
||
*/
|
||
function buildStateMd1230({ fmStatus = 'completed', fmStoppedAt = null, bodyStatus = 'Verifying Phase 3', bodyStoppedAt = null } = {}) {
|
||
const fmLines = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
`status: ${fmStatus}`,
|
||
];
|
||
if (fmStoppedAt) fmLines.push(`stopped_at: "${fmStoppedAt}"`);
|
||
fmLines.push('---');
|
||
|
||
const bodyLines = [
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
`Status: ${bodyStatus}`,
|
||
'Last Activity: 2026-01-01',
|
||
`Current Phase: 3`,
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
||
];
|
||
if (bodyStoppedAt) bodyLines.push(`**Stopped at:** ${bodyStoppedAt}`);
|
||
bodyLines.push('');
|
||
|
||
return [...fmLines, ...bodyLines].join('\n');
|
||
}
|
||
|
||
describe('bug #1230: readModifyWriteStateMd preserves frontmatter status/stopped_at when write does not change body source field', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// (a) CORE: record-session with stale body Status leaves frontmatter status: completed intact
|
||
test('(a) record-session does NOT revert frontmatter status: completed when body Status is unchanged', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// frontmatter status: completed; body Status: Verifying Phase 3 (derives 'verifying')
|
||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
||
|
||
const result = runGsdTools(
|
||
['state', 'record-session', '--stopped-at', 'Phase 3 final review checkpoint'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `record-session failed: ${result.error}`);
|
||
|
||
// Assert on raw file frontmatter — not state json (which re-derives)
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
assert.strictEqual(
|
||
rawFm.status,
|
||
'completed',
|
||
`frontmatter status must remain 'completed' after record-session (body Status unchanged); got: ${JSON.stringify(rawFm.status)}`,
|
||
);
|
||
});
|
||
|
||
// (b) add-decision (resync:true) with frontmatter status: completed, stale body → status preserved
|
||
test('(b) add-decision (resync:true) does NOT revert frontmatter status: completed when body Status unchanged', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
||
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '3', '--summary', 'Use Node 22'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
assert.strictEqual(
|
||
rawFm.status,
|
||
'completed',
|
||
`frontmatter status must remain 'completed' after add-decision; got: ${JSON.stringify(rawFm.status)}`,
|
||
);
|
||
});
|
||
|
||
// (c) LEGITIMATE UPDATE NOT FROZEN: begin-phase changes body Status → frontmatter re-derived correctly.
|
||
//
|
||
// stateReplaceField(content, 'Status', ...) replaces the FIRST ^Status: match in the
|
||
// full content (frontmatter + body). When the frontmatter has NO 'status:' key, the
|
||
// match falls through to the body 'Status:' line, which IS changed. The delta then
|
||
// fires (preBodyStatus ≠ postBodyStatus), the preservation guard is skipped, and
|
||
// syncStateFrontmatter re-derives from the new body value as intended.
|
||
test('(c) begin-phase changes body Status → frontmatter status reflects new body-derived value', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Frontmatter intentionally has NO 'status:' key so stateReplaceField targets the body
|
||
// 'Status:' line. After the transform, body Status becomes "Executing Phase 3" →
|
||
// normalizeStateStatus → 'executing' must be written to frontmatter.
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Status: Ready to execute',
|
||
'Last Activity: 2026-01-01',
|
||
'Current Phase: 2',
|
||
'Current Phase Name: Planning',
|
||
'Current Plan: 1',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 2 (Planning) — READY',
|
||
'Plan: 1 of 1',
|
||
'Status: Ready to execute',
|
||
'Last activity: 2026-01-01 -- Phase 2 planning complete',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'begin-phase', '--phase', '3', '--name', 'Execution'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `begin-phase failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
// begin-phase changed body Status to "Executing Phase 3" → delta fired → re-derived → 'executing'
|
||
assert.strictEqual(
|
||
rawFm.status,
|
||
'executing',
|
||
`frontmatter status must be updated to 'executing' when begin-phase changes body Status; got: ${JSON.stringify(rawFm.status)}`,
|
||
);
|
||
});
|
||
|
||
// (d) stopped_at: TRUE RED — frontmatter stopped_at preserved when body Stopped at differs
|
||
//
|
||
// Change C: this is a TRUE regression guard. Frontmatter stopped_at ("Phase 7 verified PASS")
|
||
// differs from the body ## Session "Stopped at:" value ("Phase 3 work"). The write operation
|
||
// (add-decision) does NOT touch the Session Stopped at line. The delta heuristic must detect
|
||
// that the Session Stopped at did NOT change (pre == post == "Phase 3 work") and therefore
|
||
// preserve the frontmatter value "Phase 7 verified PASS". Pre-fix code would REVERT to
|
||
// "Phase 3 work" (the body-derived value) — making this a genuine red.
|
||
test('(d) add-decision preserves frontmatter stopped_at when body Session Stopped at differs from frontmatter and is unchanged', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: completed',
|
||
'stopped_at: "Phase 7 verified PASS"',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Status: Phase 3 complete',
|
||
'Last Activity: 2026-01-01',
|
||
'Current Phase: 3',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
||
// body Session Stopped at is STALE (different from frontmatter stopped_at)
|
||
'**Stopped at:** Phase 3 work',
|
||
'**Resume file:** None',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// add-decision does NOT touch ## Session Stopped at → pre and post body value identical
|
||
// ("Phase 3 work" unchanged) → delta fires → frontmatter "Phase 7 verified PASS" preserved.
|
||
const result = runGsdTools(
|
||
['state', 'add-decision', '--phase', '3', '--summary', 'Preserve stopped_at check'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
assert.strictEqual(
|
||
rawFm.stopped_at,
|
||
'Phase 7 verified PASS',
|
||
`frontmatter stopped_at must be preserved ("Phase 7 verified PASS") when body Session Stopped at ` +
|
||
`is unchanged (stale "Phase 3 work"); got: ${JSON.stringify(rawFm.stopped_at)}`,
|
||
);
|
||
});
|
||
|
||
// (f) PRODUCTION-PATH: legitimate status transition is NOT frozen by the delta heuristic.
|
||
//
|
||
// Change A: prove that begin-phase on a STATE.md with inline "Status: Executing Phase 1"
|
||
// (the standard template format) correctly transitions frontmatter status from
|
||
// 'executing' to a new 'executing' value when the body Status CHANGES.
|
||
// More critically: also verify a complete-phase → frontmatter becomes 'completed'
|
||
// when the body Status field IS changed. This locks in that the delta heuristic
|
||
// re-derives correctly whenever the body's Status source field actually changes.
|
||
test('(f) begin-phase changes inline body Status → delta fires → frontmatter status updated (not frozen)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Realistic STATE.md: frontmatter status: executing, body Status: Executing Phase 1 (inline format)
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: executing',
|
||
'current_phase: 1',
|
||
'current_phase_name: Planning',
|
||
'current_plan: 1',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'Current Phase: 1',
|
||
'Current Phase Name: Planning',
|
||
'Current Plan: 1',
|
||
'Total Plans in Phase: 2',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
// begin-phase 2 changes body Status from "Executing Phase 1" to "Executing Phase 2"
|
||
// → pre and post body Status differ → delta does NOT fire → syncStateFrontmatter
|
||
// re-derives status from new body value → frontmatter status must reflect 'executing'
|
||
// (still 'executing' after begin-phase 2 is a correct transition).
|
||
const beginResult = runGsdTools(
|
||
['state', 'begin-phase', '--phase', '2', '--name', 'Implementation'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(beginResult.success, `state begin-phase failed: ${beginResult.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
// Frontmatter status must have been updated (not frozen at original 'executing' for phase 1).
|
||
// After begin-phase 2, body Status becomes "Executing Phase 2" → re-derived → still 'executing'
|
||
// but it must NOT be the stale body-derived value from before the transform; the key check is
|
||
// that the write completed successfully and status is a known valid value.
|
||
assert.ok(
|
||
rawFm.status === 'executing',
|
||
`frontmatter status must be 'executing' after begin-phase 2 (delta fires, re-derived); got: ${JSON.stringify(rawFm.status)}`,
|
||
);
|
||
|
||
// Stronger check: if we then run a command that changes Status to a DIFFERENT value,
|
||
// the frontmatter MUST reflect the new body-derived status (not be frozen).
|
||
// Use state update to change Status to "Phase 2 complete" → derives 'completed'.
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Status', 'Phase 2 complete'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update Status failed: ${updateResult.error}`);
|
||
|
||
const written2 = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm2 = parseFrontmatter(written2);
|
||
assert.strictEqual(
|
||
rawFm2.status,
|
||
'completed',
|
||
`frontmatter status must be 'completed' after body Status → 'Phase 2 complete' (delta fires, re-derived); ` +
|
||
`got: ${JSON.stringify(rawFm2.status)}. The delta heuristic must NOT freeze status when the body field changes.`,
|
||
);
|
||
});
|
||
|
||
// (e) milestone-switch uses platformWriteSync directly (not RMW) — check it still resets status correctly
|
||
test('(e) milestone-switch still resets frontmatter status to planning (uses writeStateMd path, not RMW)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Phase 3 complete' }));
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## v2.0 Next\n\n### Phase 4: Next steps\n',
|
||
'utf-8',
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `milestone-switch failed: ${result.error}`);
|
||
|
||
const written = fs.readFileSync(statePath, 'utf8');
|
||
const rawFm = parseFrontmatter(written);
|
||
assert.strictEqual(
|
||
rawFm.status,
|
||
'planning',
|
||
`milestone-switch must reset frontmatter status to 'planning'; got: ${JSON.stringify(rawFm.status)}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-948-state-noop-write-guard.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-948-state-noop-write-guard (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression guard for bugs #948 and #944.
|
||
*
|
||
* #948 (data loss): a `state patch` whose fields all fail to match still
|
||
* rewrites STATE.md — bumping `last_updated`, resetting `milestone_name` to
|
||
* the template placeholder, and resurrecting a stale `stopped_at` from an
|
||
* old body `## Session` block (body-derived value overwrites a newer
|
||
* frontmatter value written by `record-session`).
|
||
*
|
||
* #944: `state record-session --stopped-at X --resume-file Y` silently
|
||
* drops the supplied values when the STATE.md body lacks the exact session
|
||
* labels the in-place replace expects, returning `{"recorded": false}` at
|
||
* exit 0 and only bumping `last_updated`.
|
||
*
|
||
* Shared root cause: `readModifyWriteStateMd` always writes STATE.md even
|
||
* when the transform produced no change, and `syncStateFrontmatter`
|
||
* re-derives frontmatter (including milestone_name / stopped_at) from the
|
||
* possibly-stale body on every write.
|
||
*
|
||
* Fixes:
|
||
* 1. No-op guard in `readModifyWriteStateMd`: when transform output ===
|
||
* input, skip the write entirely.
|
||
* 2. `syncStateFrontmatter` preserves existing `milestone_name` / `milestone`
|
||
* when the derived value is the template placeholder `'milestone'`.
|
||
* 3. `syncStateFrontmatter` prefers existing frontmatter `stopped_at` /
|
||
* `paused_at` over a body-derived value (frontmatter wins).
|
||
* 4. `cmdStateRecordSession` auto-creates a canonical `## Session` section
|
||
* when `--stopped-at` / `--resume-file` are supplied but no labels exist.
|
||
*/
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Fixture builders
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* STATE.md with:
|
||
* - real `milestone_name` in frontmatter (e.g. "My Real Milestone")
|
||
* - newer frontmatter `stopped_at` (written by a prior `record-session`)
|
||
* - stale `## Session` body section with an OLDER "Stopped at" line
|
||
*
|
||
* When a zero-match `state patch` runs on this file, NONE of these values
|
||
* should be disturbed — the file must be byte-identical afterward.
|
||
*/
|
||
function buildStateMdWithStaleSectionAndRealFrontmatter(opts) {
|
||
const {
|
||
milestoneName = 'My Real Milestone',
|
||
fmStoppedAt = 'Phase 3, Plan 2 — newer value',
|
||
bodyStoppedAt = 'Phase 1, Plan 1 — stale historical value',
|
||
lastUpdated = '2026-01-01T00:00:00.000Z',
|
||
} = opts || {};
|
||
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v2.0',
|
||
`milestone_name: ${milestoneName}`,
|
||
'status: executing',
|
||
`stopped_at: ${fmStoppedAt}`,
|
||
`last_updated: ${lastUpdated}`,
|
||
'progress:',
|
||
' total_phases: 5',
|
||
' completed_phases: 2',
|
||
' total_plans: 10',
|
||
' completed_plans: 4',
|
||
' percent: 40',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Status: Executing Phase 3',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
`**Last session:** 2026-01-01T00:00:00.000Z`,
|
||
`**Stopped at:** ${bodyStoppedAt}`,
|
||
'**Resume file:** None',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Decisions',
|
||
'',
|
||
'- [Phase 1]: Use Node 22',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* STATE.md with NO session section at all — no "## Session" heading,
|
||
* no Stopped at / Resume file labels. This is the #944 scenario.
|
||
*/
|
||
function buildStateMdWithoutSessionSection() {
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Decisions',
|
||
'',
|
||
'- [Phase 1]: Use TypeScript',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* STATE.md with a canonical session section (the success path — must not regress).
|
||
*/
|
||
function buildStateMdWithCanonicalSessionSection() {
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
||
'**Stopped at:** Phase 1, Plan 1',
|
||
'**Resume file:** None',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'### Decisions',
|
||
'',
|
||
'- Use TypeScript',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #948: zero-match patch must leave STATE.md byte-identical
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#948: zero-match state patch must not rewrite STATE.md', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('STATE.md is byte-identical after a zero-match patch', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({});
|
||
fs.writeFileSync(statePath, original);
|
||
|
||
// Patch a field that does NOT exist in the file — zero matches expected.
|
||
const result = runGsdTools('state patch --NonExistentFieldXYZ "some value"', tmpDir);
|
||
assert.ok(result.success, `state patch should exit 0: ${result.error}`);
|
||
|
||
const patchOutput = JSON.parse(result.output);
|
||
assert.deepStrictEqual(patchOutput.updated, [], 'updated should be empty');
|
||
assert.ok(Array.isArray(patchOutput.failed), 'failed should be an array');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.strictEqual(after, original, 'STATE.md must be byte-identical after zero-match patch');
|
||
});
|
||
|
||
test('milestone_name is preserved after zero-match patch (not reset to template placeholder)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
||
milestoneName: 'My Real Milestone',
|
||
});
|
||
fs.writeFileSync(statePath, original);
|
||
|
||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm['milestone_name'], 'My Real Milestone',
|
||
'milestone_name must not be reset to template placeholder by zero-match patch');
|
||
});
|
||
|
||
test('stopped_at frontmatter value is preserved after zero-match patch (via byte-identity)', () => {
|
||
// The no-op guard prevents ANY rewrite when nothing changed, so the
|
||
// frontmatter stopped_at is preserved because the file is never touched.
|
||
// The stale body value cannot win because syncStateFrontmatter is never called.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
||
fmStoppedAt: 'Phase 3, Plan 2 — newer value',
|
||
bodyStoppedAt: 'Phase 1, Plan 1 — stale historical value',
|
||
});
|
||
fs.writeFileSync(statePath, original);
|
||
|
||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
||
|
||
// The byte-identity test already covers this; this test confirms the key
|
||
// field specifically is intact.
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.strictEqual(after, original,
|
||
'STATE.md must be byte-identical — stopped_at cannot be overwritten via a no-op patch');
|
||
});
|
||
|
||
test('last_updated is not bumped by a zero-match patch', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
||
lastUpdated: '2026-01-01T00:00:00.000Z',
|
||
});
|
||
fs.writeFileSync(statePath, original);
|
||
|
||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm['last_updated'], '2026-01-01T00:00:00.000Z',
|
||
'last_updated must not be bumped when no fields were changed');
|
||
});
|
||
|
||
test('a matching patch STILL updates STATE.md correctly (no regression)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const fixture = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'**Status:** In Progress',
|
||
'**Last Activity:** 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, fixture);
|
||
|
||
const result = runGsdTools('state patch --Status "Phase complete — ready for verification"', tmpDir);
|
||
assert.ok(result.success, `state patch should succeed: ${result.error}`);
|
||
|
||
const patchOutput = JSON.parse(result.output);
|
||
assert.ok(patchOutput.updated.includes('Status'), 'Status should be in updated list');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('Phase complete — ready for verification'),
|
||
'matching patch should update the field');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #948: syncStateFrontmatter — milestone_name placeholder preservation
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#948: syncStateFrontmatter preserves milestone_name when derived is template placeholder', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state sync preserves real milestone_name when disk yields only template placeholder', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Frontmatter has a real name, but no ROADMAP.md exists so getMilestoneInfo
|
||
// will fall back to the 'milestone' placeholder — must not overwrite.
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v2.5',
|
||
'milestone_name: Very Real Project Name',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
|
||
'milestone_name must not be reset to template placeholder by state sync');
|
||
});
|
||
|
||
test('state sync runs successfully and preserves milestone_name (no corruption)', () => {
|
||
// state sync always rebuilds frontmatter from the body — the no-op guard
|
||
// applies to commands whose transform produces no change. state sync always
|
||
// writes because last_updated changes. This test verifies that a full sync
|
||
// cycle does not corrupt milestone_name when the placeholder is derived.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const content = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v2.5',
|
||
'milestone_name: Very Real Project Name',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, content);
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
|
||
'state sync must not reset milestone_name to template placeholder');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #944: record-session with no session section must persist supplied values
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#944: record-session persists values even when body lacks session labels', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('stopped-at and resume-file are present in STATE.md after record-session with no prior section', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T12:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 2, Plan 3" --resume-file ".planning/phases/02/02-03-PLAN.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `state record-session should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true,
|
||
'recorded must be true when values were supplied and persisted');
|
||
assert.ok(!output.reason || output.reason !== 'No session fields found in STATE.md',
|
||
'must not return the silent no-op reason when values were supplied');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('Phase 2, Plan 3'),
|
||
'--stopped-at value must appear in STATE.md');
|
||
assert.ok(after.includes('.planning/phases/02/02-03-PLAN.md'),
|
||
'--resume-file value must appear in STATE.md');
|
||
});
|
||
|
||
test('command does not silently no-op when values are supplied (recorded must not be false)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
||
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 5, Plan 1"',
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// The key contract: if values were supplied, recorded must be true.
|
||
assert.notStrictEqual(output.recorded, false,
|
||
'recorded must not be false when --stopped-at was explicitly supplied');
|
||
});
|
||
|
||
test('STATE.md with non-canonical session labels still persists supplied values', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Session section exists but uses non-canonical label shapes (table, alternate caps)
|
||
const nonCanonical = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Session Info',
|
||
'',
|
||
'| Field | Value |',
|
||
'|-------|-------|',
|
||
'| Last Session | 2026-01-01 |',
|
||
'| Stopped Here | Phase 1, Plan 1 |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, nonCanonical);
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T15:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 3, Plan 2" --resume-file "none.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true,
|
||
'recorded must be true when values are persisted via auto-create fallback');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('Phase 3, Plan 2'),
|
||
'--stopped-at value must be present in STATE.md');
|
||
assert.ok(after.includes('none.md'),
|
||
'--resume-file value must be present in STATE.md');
|
||
});
|
||
|
||
test('record-session with no args against a body-less file returns recorded:false (no regression)', () => {
|
||
// When NO values are supplied and no session fields can be found/updated,
|
||
// recorded:false is the correct behaviour — we only changed the contract
|
||
// when the caller supplies values.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
||
|
||
const result = runGsdTools('state record-session', tmpDir);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, false,
|
||
'recorded should still be false when no session fields exist AND no values were supplied');
|
||
});
|
||
|
||
test('canonical session section still updates in place (no regression)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T18:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 2, Plan 4" --resume-file ".planning/phases/02/02-04-PLAN.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('Phase 2, Plan 4'), 'stopped-at should be updated');
|
||
assert.ok(after.includes('.planning/phases/02/02-04-PLAN.md'), 'resume-file should be updated');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Adversarial fixtures: malformed frontmatter, missing fields, CRLF
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#948/#944: adversarial fixture variants', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('zero-match patch on CRLF STATE.md leaves file unchanged', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Build with CRLF line endings
|
||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({}).replace(/\n/g, '\r\n');
|
||
fs.writeFileSync(statePath, original);
|
||
|
||
runGsdTools('state patch --NonExistentFieldXYZ "value"', tmpDir);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.strictEqual(after, original, 'CRLF file must be byte-identical after zero-match patch');
|
||
});
|
||
|
||
test('zero-match patch on STATE.md with missing frontmatter fields does not corrupt', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const minimal = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'**Status:** In Progress',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, minimal);
|
||
|
||
const result = runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const patchOutput = JSON.parse(result.output);
|
||
assert.deepStrictEqual(patchOutput.updated, [], 'no fields should be updated');
|
||
});
|
||
|
||
test('record-session with empty body still records when values supplied', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
// Body is entirely empty (only frontmatter)
|
||
const emptyBody = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: planning',
|
||
'---',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, emptyBody);
|
||
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 1, Plan 1"',
|
||
tmpDir,
|
||
);
|
||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.recorded, true,
|
||
'should persist even into a body-less STATE.md');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('Phase 1, Plan 1'),
|
||
'--stopped-at value must appear in STATE.md');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Adversarial review findings: in-place update for existing ## Session heading
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#944 adversarial: existing ## Session heading must be updated in place, not duplicated', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
/**
|
||
* HIGH finding: when a `## Session` heading already exists but uses
|
||
* non-canonical rows (e.g. a markdown table), the DWIM code was appending
|
||
* a second `## Session` block instead of normalizing the existing one.
|
||
* buildStateFrontmatter / cmdStateSnapshot both read only the FIRST match,
|
||
* so the newly-written Stopped at / Resume file end up in an ignored block.
|
||
*/
|
||
test('record-session with existing non-canonical ## Session block: exactly one ## Session block afterward', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const nonCanonicalWithHeading = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'| Field | Value |',
|
||
'|-------|-------|',
|
||
'| Last Session | 2026-01-01 |',
|
||
'| Stopped Here | Phase 1, Plan 1 |',
|
||
'',
|
||
'## Accumulated Context',
|
||
'',
|
||
'- Decision: use TypeScript',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, nonCanonicalWithHeading);
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T20:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
|
||
// (a) exactly ONE ## Session block — no duplicate
|
||
const sessionHeadingCount = (after.match(/^## Session\s*$/gm) || []).length;
|
||
assert.strictEqual(sessionHeadingCount, 1,
|
||
'exactly ONE ## Session block must exist after record-session (no duplicate appended)');
|
||
|
||
// (b) supplied values are present in the file
|
||
assert.ok(after.includes('Phase 4, Plan 2'),
|
||
'--stopped-at value must be present in STATE.md');
|
||
assert.ok(after.includes('resume.md'),
|
||
'--resume-file value must be present in STATE.md');
|
||
});
|
||
|
||
test('record-session with existing non-canonical ## Session block: state-snapshot sees supplied stopped_at', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const nonCanonicalWithHeading = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'| Field | Value |',
|
||
'|-------|-------|',
|
||
'| Last Session | 2026-01-01 |',
|
||
'| Stopped Here | Phase 1, Plan 1 |',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, nonCanonicalWithHeading);
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T20:30:00.000Z');
|
||
runGsdTools(
|
||
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
|
||
// (c) state-snapshot must see the written stopped_at in the session block
|
||
// (via buildStateFrontmatter frontmatter OR body Session section, first match)
|
||
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
|
||
const snapshot = JSON.parse(snapshotResult.output);
|
||
assert.strictEqual(
|
||
snapshot.session && snapshot.session.stopped_at,
|
||
'Phase 4, Plan 2',
|
||
`state-snapshot session.stopped_at must reflect "Phase 4, Plan 2", got: ${JSON.stringify(snapshot.session)}`,
|
||
);
|
||
});
|
||
|
||
/**
|
||
* LOW finding: auto-created scaffold writes `**Last session:**` but
|
||
* cmdStateSnapshot only matched `**Last Date:**`, so session.last_date
|
||
* was null after auto-create despite a valid timestamp being written.
|
||
* Fix: teach the snapshot parser to also accept `**Last session:**`.
|
||
*/
|
||
test('state-snapshot returns non-null session.last_date after auto-create on body-less file', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
||
|
||
const PINNED_MS = Date.parse('2026-06-09T21:00:00.000Z');
|
||
const recResult = runGsdTools(
|
||
'state record-session --stopped-at "Phase 1, Plan 1"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(recResult.success, `record-session should exit 0: ${recResult.error}`);
|
||
|
||
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
|
||
const snapshot = JSON.parse(snapshotResult.output);
|
||
assert.notStrictEqual(
|
||
snapshot.session && snapshot.session.last_date,
|
||
null,
|
||
`state-snapshot session.last_date must not be null after auto-create; got: ${JSON.stringify(snapshot.session)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug #1101: record-session on a `## Session Continuity` bootstrap section must
|
||
// update IN PLACE, not append a duplicate `## Session` block.
|
||
//
|
||
// The reported symptom (recorded:false + frontmatter still mutated) is already
|
||
// fixed by #944/#948. The residual: the DWIM auto-create recognised only the
|
||
// canonical `## Session` heading, so a bootstrap `## Session Continuity` section
|
||
// (workstream.cts, gsd2-import.cts, templates/state.md) fell through to the
|
||
// append branch and produced a SECOND `## Session` block. The fix inserts the
|
||
// missing canonical fields into the existing `## Session Continuity` section,
|
||
// preserving the heading and any prose, and teaches the snapshot / frontmatter
|
||
// readers to recognise that heading.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#1101: record-session updates ## Session Continuity in place (no duplicate block)', () => {
|
||
let tmpDir;
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
/** Workstream bootstrap shape: bold Stopped At/Resume File, no Last session. */
|
||
function buildWorkstreamContinuity() {
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# State: example',
|
||
'',
|
||
'## Session Continuity',
|
||
'**Stopped At:** N/A',
|
||
'**Resume File:** None',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
test('workstream Session Continuity is updated in place — no duplicate ## Session appended', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildWorkstreamContinuity());
|
||
|
||
const PINNED_MS = Date.parse('2026-06-12T12:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 1 context gathered" --resume-file ".planning/phases/01/01-CONTEXT.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
assert.strictEqual(out.recorded, true, 'recorded must be true when values are supplied');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
// No duplicate bare `## Session` heading appended (the only heading stays
|
||
// `## Session Continuity`).
|
||
assert.ok(!/^## Session[ \t]*$/m.test(after),
|
||
`must not append a duplicate bare "## Session" block; got:\n${after}`);
|
||
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
|
||
`exactly one Session-family heading must remain; got:\n${after}`);
|
||
// Missing canonical field inserted; supplied values present.
|
||
assert.ok(after.includes('**Last session:**'), 'Last session field must be inserted');
|
||
assert.ok(after.includes('Phase 1 context gathered'), '--stopped-at value must be present');
|
||
assert.ok(after.includes('.planning/phases/01/01-CONTEXT.md'), '--resume-file value must be present');
|
||
// The frontmatter reader recognises `## Session Continuity` and derives stopped_at.
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm.stopped_at, 'Phase 1 context gathered',
|
||
'frontmatter stopped_at must be derived from the ## Session Continuity section');
|
||
// The cmdStateSnapshot reader (separate code path) must also resolve it.
|
||
const snap = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
||
const snapshot = JSON.parse(snap.output);
|
||
assert.strictEqual(snapshot.session.stopped_at, 'Phase 1 context gathered',
|
||
'state-snapshot must read stopped_at from the ## Session Continuity section');
|
||
});
|
||
|
||
test('prose under ## Session Continuity is preserved (no data loss)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const withProse = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# State: example',
|
||
'',
|
||
'## Session Continuity',
|
||
'',
|
||
'**Next recommended action:** keep-me-intact',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, withProse);
|
||
|
||
const PINNED_MS = Date.parse('2026-06-12T13:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 2 done" --resume-file "none.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
assert.strictEqual(out.recorded, true, 'recorded must be true');
|
||
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.ok(after.includes('**Next recommended action:** keep-me-intact'),
|
||
`existing prose must be preserved (no data loss); got:\n${after}`);
|
||
assert.ok(!/^## Session[ \t]*$/m.test(after),
|
||
'must not append a duplicate bare "## Session" block');
|
||
assert.ok(after.includes('Phase 2 done'), '--stopped-at value must be present');
|
||
const fm = parseFrontmatter(after);
|
||
assert.strictEqual(fm.stopped_at, 'Phase 2 done',
|
||
'frontmatter stopped_at must be derived from the ## Session Continuity section');
|
||
});
|
||
|
||
test('canonical ## Session block path is unchanged (no regression)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
|
||
|
||
const PINNED_MS = Date.parse('2026-06-12T14:00:00.000Z');
|
||
const result = runGsdTools(
|
||
'state record-session --stopped-at "Phase 9, Plan 9" --resume-file "r.md"',
|
||
tmpDir,
|
||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
||
);
|
||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
||
const after = fs.readFileSync(statePath, 'utf-8');
|
||
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
|
||
'canonical ## Session block must remain single');
|
||
assert.ok(after.includes('Phase 9, Plan 9'), 'stopped-at value updated in canonical block');
|
||
});
|
||
|
||
test('legacy duplicate file: reader PREFERS canonical ## Session over ## Session Continuity (F1)', () => {
|
||
// A file created by the OLD bug: a stale `## Session Continuity` first, then an
|
||
// appended fresh `## Session`. The snapshot reader must read the canonical
|
||
// `## Session` (fresh), matching the writer, not the stale Continuity block.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const duplicate = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# State: example',
|
||
'',
|
||
'## Session Continuity',
|
||
'**Stopped At:** STALE-continuity-value',
|
||
'**Resume File:** None',
|
||
'',
|
||
'## Session',
|
||
'',
|
||
'**Last session:** 2026-06-12T10:00:00.000Z',
|
||
'**Stopped at:** FRESH-canonical-value',
|
||
'**Resume file:** r.md',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, duplicate);
|
||
|
||
const snap = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
||
const snapshot = JSON.parse(snap.output);
|
||
assert.strictEqual(snapshot.session.stopped_at, 'FRESH-canonical-value',
|
||
'reader must prefer the canonical ## Session block over the stale ## Session Continuity');
|
||
});
|
||
|
||
test('h3 ### Session Continuity is NOT read as the session section (F4)', () => {
|
||
// The reader is line-anchored to `^## `, so an h3 subsection must not be picked
|
||
// up as the session section.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const h3Only = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Foundation',
|
||
'status: executing',
|
||
'last_updated: 2026-01-01T00:00:00.000Z',
|
||
'---',
|
||
'',
|
||
'# State: example',
|
||
'',
|
||
'### Session Continuity',
|
||
'**Last session:** 2026-06-12T10:00:00.000Z',
|
||
'**Stopped at:** h3-should-not-be-session',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(statePath, h3Only);
|
||
|
||
const snap = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
||
const snapshot = JSON.parse(snap.output);
|
||
assert.strictEqual(snapshot.session.last_date, null,
|
||
'an h3 ### Session Continuity must not be treated as the ## Session section');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3275-fmstr-non-string-scalars.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3275-fmstr-non-string-scalars (consolidation epic #1969 B2 #1971)", () => {
|
||
/**
|
||
* GSD Tools Tests — Bug #3275 (CR finding)
|
||
*
|
||
* Regression guard: `state-snapshot` must prefer YAML frontmatter scalar
|
||
* values even when those scalars are numeric (e.g. current_phase: 19) or
|
||
* boolean — not just when they are strings.
|
||
*
|
||
* Prior to the fix, `fmStr` checked `typeof v === 'string'`, so a numeric
|
||
* frontmatter value like `current_phase: 19` was treated as missing and the
|
||
* snapshot fell back to body extraction, which could return a stale or
|
||
* incorrect value.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
describe('state-snapshot: fmStr accepts non-string YAML scalars (#3275 CR)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('numeric current_phase in frontmatter wins over body extraction', () => {
|
||
// YAML parses bare integers as numbers, not strings.
|
||
// fmStr must not drop the frontmatter value when it is a number.
|
||
const stateMd = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'current_phase: 19',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 03',
|
||
'**Status:** executing',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// Frontmatter numeric value must win over bold-body value
|
||
assert.strictEqual(output.current_phase, '19', 'numeric frontmatter current_phase must be used');
|
||
});
|
||
|
||
test('numeric total_phases in frontmatter wins over body extraction', () => {
|
||
const stateMd = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'total_phases: 7',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Total Phases:** 3',
|
||
'**Status:** executing',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// Frontmatter says 7, body says 3 — frontmatter must win
|
||
assert.strictEqual(output.total_phases, 7, 'numeric frontmatter total_phases must be used');
|
||
});
|
||
|
||
test('numeric total_plans_in_phase in frontmatter wins over body extraction', () => {
|
||
const stateMd = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'total_plans_in_phase: 5',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Total Plans in Phase:** 2',
|
||
'**Status:** executing',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.total_plans_in_phase, 5, 'numeric frontmatter total_plans_in_phase must be used');
|
||
});
|
||
|
||
test('string current_phase in frontmatter still works (no regression)', () => {
|
||
const stateMd = [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
"current_phase: '19'",
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 03',
|
||
'**Status:** executing',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_phase, '19', 'string frontmatter current_phase still works');
|
||
});
|
||
|
||
test('no-frontmatter file still extracts from body (no regression)', () => {
|
||
const stateMd = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 05',
|
||
'**Total Phases:** 8',
|
||
'**Status:** paused',
|
||
'',
|
||
].join('\n');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||
|
||
const result = runGsdTools('state-snapshot', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.current_phase, '05', 'body extraction still works without frontmatter');
|
||
assert.strictEqual(output.total_phases, 8, 'numeric body total_phases still extracted');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3257-nested-plans-undercount.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3257-nested-plans-undercount (consolidation epic #1969 B2 #1971)", () => {
|
||
/**
|
||
* GSD Tools Tests — Bug #3257
|
||
*
|
||
* Regression guard: `buildStateFrontmatter` must count plan/summary files in
|
||
* the nested `phases/<N>-<slug>/plans/<N>-PLAN-<NN>-<slug>.md` layout (written
|
||
* by gsd-plan-phase post-#3139). Prior to this fix, the loop did a flat
|
||
* `readdirSync` on the phase directory and missed every file inside the
|
||
* `plans/` subdirectory, so `progress.total_plans` and
|
||
* `progress.completed_plans` were silently under-counted on every state
|
||
* mutation that flows through `syncStateFrontmatter → buildStateFrontmatter`.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Write a minimal STATE.md that will trigger syncStateFrontmatter on any write.
|
||
*/
|
||
function writeStateFile(tmpDir, overrides = {}) {
|
||
const phase = overrides.phase || '01';
|
||
const status = overrides.status || 'executing';
|
||
const content = [
|
||
'# Project State',
|
||
'',
|
||
`**Current Phase:** ${phase}`,
|
||
`**Status:** ${status}`,
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
`Phase: ${phase} — In progress`,
|
||
'Status: Executing',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content, 'utf-8');
|
||
}
|
||
|
||
/**
|
||
* Write a ROADMAP.md listing the given phase numbers so the milestone-scoped
|
||
* filter includes them (avoids needing a milestone header to count phases).
|
||
*/
|
||
function writeRoadmap(tmpDir, phaseNums) {
|
||
const lines = ['## Roadmap v1.0'];
|
||
for (const n of phaseNums) {
|
||
lines.push('', `### Phase ${n}: Phase ${n}`);
|
||
}
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
lines.join('\n'),
|
||
'utf-8'
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Nested layout — core bug (#3257)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('counts plans and summaries in nested plans/ subdirectory', () => {
|
||
// Layout: phases/01-init/plans/1-PLAN-01-setup.md etc.
|
||
// 2 phases × 3 plans each, all completed (3 summaries each).
|
||
for (let phase = 1; phase <= 2; phase++) {
|
||
const phaseSlug = `0${phase}-phase-${phase}`;
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let plan = 1; plan <= 3; plan++) {
|
||
const planPad = String(plan).padStart(2, '0');
|
||
// Reporter's format: {N}-PLAN-{NN}-{slug}.md
|
||
const planFile = `${phase}-PLAN-${planPad}-step${plan}.md`;
|
||
const summaryFile = `${phase}-SUMMARY-${planPad}-step${plan}.md`;
|
||
fs.writeFileSync(path.join(plansDir, planFile), '# Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, summaryFile), '# Summary\n');
|
||
}
|
||
}
|
||
|
||
writeRoadmap(tmpDir, [1, 2]);
|
||
writeStateFile(tmpDir, { phase: '02' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
assert.strictEqual(Number(progress.total_plans), 6, 'total_plans must count nested plans/ files (2 phases × 3 plans)');
|
||
assert.strictEqual(Number(progress.completed_plans), 6, 'completed_plans must count nested summary files (2 phases × 3 summaries)');
|
||
assert.strictEqual(Number(progress.completed_phases), 2, 'completed_phases: both phases have summaries >= plans');
|
||
});
|
||
|
||
test('counts PLAN-NN-slug form (bare PLAN- prefix, no phase prefix)', () => {
|
||
// roadmap.cjs uses /^PLAN-\d+.*\.md$/i — test that form too.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(plansDir, 'PLAN-01-foundation.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, 'PLAN-02-infra.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, 'SUMMARY-01-foundation.md'), '# Summary\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
assert.strictEqual(Number(progress.total_plans), 2, 'bare PLAN-NN-slug.md files must be counted');
|
||
assert.strictEqual(Number(progress.completed_plans), 1, 'SUMMARY-NN-slug.md files must be counted');
|
||
// 1 summary < 2 plans → phase NOT completed
|
||
assert.strictEqual(Number(progress.completed_phases), 0, 'phase not complete when summaries < plans');
|
||
});
|
||
|
||
test('flat-layout repos are unaffected (no plans/ subdirectory)', () => {
|
||
// Pre-#3139 flat layout: plans live directly in the phase dir.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
assert.strictEqual(Number(progress.total_plans), 2, 'flat layout: top-level *-PLAN.md files counted');
|
||
assert.strictEqual(Number(progress.completed_plans), 2, 'flat layout: top-level *-SUMMARY.md files counted');
|
||
assert.strictEqual(Number(progress.completed_phases), 1, 'flat layout: phase complete when summaries >= plans');
|
||
});
|
||
|
||
test('no double-count when both top-level and nested plan files coexist', () => {
|
||
// Edge case: phase has a top-level plan AND a plans/ subdir.
|
||
// Only the nested files should be counted (or both, depending on logic),
|
||
// but the critical thing is no file is counted twice.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
// Top-level flat plan
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Top-level Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Top-level Summary\n');
|
||
|
||
// Nested plan
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-02-nested.md'), '# Nested Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, '1-SUMMARY-02-nested.md'), '# Nested Summary\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
// 1 top-level + 1 nested = 2 total (not 4 from double-counting)
|
||
assert.strictEqual(Number(progress.total_plans), 2, 'mixed layout: no double-counting of plan files');
|
||
assert.strictEqual(Number(progress.completed_plans), 2, 'mixed layout: no double-counting of summary files');
|
||
});
|
||
|
||
test('empty plans/ directory is a no-op (does not break counting)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
// plans/ dir exists but is empty
|
||
|
||
// One top-level plan
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
assert.strictEqual(Number(progress.total_plans), 1, 'empty plans/ must not add phantom plan count');
|
||
assert.strictEqual(Number(progress.completed_plans), 1, 'empty plans/ must not affect summary count');
|
||
assert.strictEqual(Number(progress.completed_phases), 1, 'phase complete: 1 summary >= 1 plan');
|
||
});
|
||
|
||
test('PLAN-OUTLINE.md files are excluded from nested plan count', () => {
|
||
// phase.cjs explicitly excludes *-PLAN-OUTLINE.md (not real plans).
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
|
||
// Outline file — should NOT count as a plan
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
// Only the real plan should count; outline excluded.
|
||
assert.strictEqual(Number(progress.total_plans), 1, 'PLAN-OUTLINE.md must not count as a plan');
|
||
});
|
||
|
||
test('pre-bounce files are excluded from nested plan count (bare PLAN- prefix)', () => {
|
||
// CR finding: PLAN_PRE_BOUNCE_RE was /-PLAN.*\.pre-bounce\.md$/i which missed
|
||
// bare-prefix files like PLAN-01-foo.pre-bounce.md. Fixed to /\.pre-bounce\.md$/i.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
|
||
// Pre-bounce files — should NOT count as plans
|
||
fs.writeFileSync(path.join(plansDir, 'PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
|
||
|
||
writeRoadmap(tmpDir, [1]);
|
||
writeStateFile(tmpDir, { phase: '01' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
// Only the real plan should count; pre-bounce files excluded.
|
||
assert.strictEqual(Number(progress.total_plans), 1, 'pre-bounce files must not count as plans');
|
||
});
|
||
|
||
test('reporter scenario: 2 phases × multiple plans, all complete', () => {
|
||
// Mirrors the reporter's observation: after a state mutation the progress
|
||
// block should reflect the TRUE on-disk count, not an under-count.
|
||
// Phase 1: 4 plans, all with summaries.
|
||
// Phase 2: 3 plans, all with summaries.
|
||
// Expected: total=7, completed=7, completed_phases=2.
|
||
const phases = [
|
||
{ num: 1, plans: 4 },
|
||
{ num: 2, plans: 3 },
|
||
];
|
||
|
||
for (const { num, plans } of phases) {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `0${num}-phase-${num}`);
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let p = 1; p <= plans; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `${num}-PLAN-${pad}-task${p}.md`), '# Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, `${num}-SUMMARY-${pad}-task${p}.md`), '# Summary\n');
|
||
}
|
||
}
|
||
|
||
writeRoadmap(tmpDir, [1, 2]);
|
||
writeStateFile(tmpDir, { phase: '02' });
|
||
|
||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const progress = JSON.parse(jsonResult.output).progress;
|
||
assert.strictEqual(Number(progress.total_plans), 7, 'reporter scenario: total_plans must be 7');
|
||
assert.strictEqual(Number(progress.completed_plans), 7, 'reporter scenario: completed_plans must be 7');
|
||
assert.strictEqual(Number(progress.completed_phases), 2, 'reporter scenario: both phases complete');
|
||
assert.strictEqual(Number(progress.percent), 100, 'reporter scenario: 100% when all plans have summaries');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdStateValidate nested plans/ layout (#3257 — CR finding)
|
||
//
|
||
// Prior to this fix, cmdStateValidate did a flat readdirSync on the phase dir
|
||
// and returned diskPlans=0 for nested layouts, causing false drift warnings
|
||
// when STATE.md correctly said "Total Plans in Phase: 3".
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('cmdStateValidate nested plans/ layout (#3257)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no false drift warning when STATE.md plan count matches nested disk count', () => {
|
||
// Phase 01-init: 3 nested plans, 0 summaries (still executing).
|
||
// STATE.md says "Total Plans in Phase: 3" — after the fix, validate sees
|
||
// diskPlans=3 and emits no plan_count drift warning.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let p = 1; p <= 3; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
||
}
|
||
|
||
// Write STATE.md with correct plan count so validate can check for drift.
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 01',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 3',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.valid, `state validate should be valid; warnings: ${JSON.stringify(parsed.warnings)}`);
|
||
assert.deepStrictEqual(parsed.warnings, [], 'no drift warnings for nested-layout phase with correct plan count');
|
||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when nested scan matches STATE.md');
|
||
});
|
||
|
||
test('emits drift warning when STATE.md plan count does not match nested disk count', () => {
|
||
// STATE.md says 5 but only 2 plans exist on disk — validate should catch it.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let p = 1; p <= 2; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
||
}
|
||
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 01',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 5',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(!parsed.valid, 'state validate should report invalid when plan counts differ');
|
||
assert.ok(parsed.warnings.length > 0, 'at least one drift warning expected');
|
||
assert.ok(parsed.drift.plan_count, 'plan_count drift object must be present');
|
||
assert.strictEqual(parsed.drift.plan_count.disk, 2, 'disk count must reflect nested scan (2 nested plans)');
|
||
assert.strictEqual(parsed.drift.plan_count.state, 5, 'state count from STATE.md must be 5');
|
||
});
|
||
|
||
test('PLAN-OUTLINE.md excluded from nested count in validate', () => {
|
||
// Outline files must not inflate diskPlans and cause false "too few" drift.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Plan\n');
|
||
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n'); // must not count
|
||
|
||
// STATE.md claims 1 plan — correct after exclusion.
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 01',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 1',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state validate', tmpDir);
|
||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.valid, `should be valid (outline excluded); warnings: ${JSON.stringify(parsed.warnings)}`);
|
||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when outline excluded from nested count');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdStateSync nested plans/ layout (#3257 — CR finding)
|
||
//
|
||
// Prior to this fix, cmdStateSync did a flat readdirSync on each phase dir,
|
||
// returning plans=0 for nested layouts. It would set "Total Plans in Phase"
|
||
// to 0 even when plans existed inside plans/ — an under-count that corrupts
|
||
// the STATE.md progress block.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('cmdStateSync nested plans/ layout (#3257)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('updates Total Plans in Phase from 0 to correct nested count on sync', () => {
|
||
// Disk: phase 01-init with 3 nested plans, no summaries.
|
||
// STATE.md says "Total Plans in Phase: 0" (stale / pre-fix value).
|
||
// After sync, the field must be updated to 3.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let p = 1; p <= 3; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
||
}
|
||
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 01',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 0',
|
||
'**Progress:** [░░░░░░░░░░] 0%',
|
||
'**Last Activity:** 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.synced, 'sync must report synced: true');
|
||
// The "Total Plans in Phase" change must appear in the changes list.
|
||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
||
assert.ok(planCountChange, `changes must include Total Plans in Phase update; got: ${JSON.stringify(parsed.changes)}`);
|
||
assert.ok(planCountChange.includes('-> 3'), `Total Plans in Phase must update to 3; got: "${planCountChange}"`);
|
||
});
|
||
|
||
test('sync dry-run reports correct nested plan count without writing', () => {
|
||
// --verify flag: sync must report what WOULD change but not write STATE.md.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
|
||
for (let p = 1; p <= 2; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `PLAN-${pad}-task${p}.md`), '# Plan\n');
|
||
}
|
||
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 01',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 0',
|
||
'**Progress:** [░░░░░░░░░░] 0%',
|
||
'**Last Activity:** 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state sync --verify', tmpDir);
|
||
assert.ok(result.success, `state sync --verify failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.dry_run, 'dry_run must be true with --verify flag');
|
||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
||
assert.ok(planCountChange, `dry-run changes must include Total Plans in Phase; got: ${JSON.stringify(parsed.changes)}`);
|
||
assert.ok(planCountChange.includes('-> 2'), `dry-run must show correct count of 2; got: "${planCountChange}"`);
|
||
|
||
// STATE.md must be unchanged (dry-run): re-run sync --verify and confirm the
|
||
// same pending change is still reported (if STATE.md had been written, the
|
||
// change would have been applied and the second run would show no changes).
|
||
const result2 = runGsdTools('state sync --verify', tmpDir);
|
||
assert.ok(result2.success, `second dry-run failed: ${result2.error}`);
|
||
const parsed2 = JSON.parse(result2.output);
|
||
const planCountChange2 = parsed2.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
||
assert.ok(planCountChange2, 'repeated dry-run must still report pending change (file was not mutated on disk)');
|
||
});
|
||
|
||
test('sync across multiple phases with nested plans sums correctly', () => {
|
||
// Phase 01: 2 nested plans, 2 summaries (complete).
|
||
// Phase 02: 3 nested plans, 1 summary (in progress).
|
||
// Expected "Total Plans in Phase" = 3 (current/incomplete phase).
|
||
const phases = [
|
||
{ dir: '01-alpha', plans: 2, summaries: 2 },
|
||
{ dir: '02-beta', plans: 3, summaries: 1 },
|
||
];
|
||
for (const { dir, plans, summaries } of phases) {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', dir);
|
||
const plansDir = path.join(phaseDir, 'plans');
|
||
fs.mkdirSync(plansDir, { recursive: true });
|
||
for (let p = 1; p <= plans; p++) {
|
||
const pad = String(p).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-t.md`), '# Plan\n');
|
||
}
|
||
for (let s = 1; s <= summaries; s++) {
|
||
const pad = String(s).padStart(2, '0');
|
||
fs.writeFileSync(path.join(plansDir, `1-SUMMARY-${pad}-t.md`), '# Summary\n');
|
||
}
|
||
}
|
||
|
||
const stateContent = [
|
||
'# Project State',
|
||
'',
|
||
'**Current Phase:** 02',
|
||
'**Status:** executing',
|
||
'**Total Plans in Phase:** 0',
|
||
'**Progress:** [░░░░░░░░░░] 0%',
|
||
'**Last Activity:** 2026-01-01',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||
|
||
const result = runGsdTools('state sync', tmpDir);
|
||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.synced, 'sync must succeed');
|
||
// "Total Plans in Phase" reflects the current (incomplete) phase: 02-beta has 3 plans.
|
||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
||
assert.ok(planCountChange, `Total Plans in Phase change expected; got: ${JSON.stringify(parsed.changes)}`);
|
||
assert.ok(planCountChange.includes('-> 3'), `current phase plan count must be 3; got: "${planCountChange}"`);
|
||
// Progress: computeProgressPercent uses min(plan_fraction, phase_fraction).
|
||
// plan_fraction = 3 summaries / 5 plans = 60%.
|
||
// phase_fraction = 1 completed phase / 2 total phases = 50%.
|
||
// min(60%, 50%) = 50% — the phase cap applies (#3242).
|
||
const progressChange = parsed.changes.find(c => c.startsWith('Progress:'));
|
||
assert.ok(progressChange, `Progress change expected; got: ${JSON.stringify(parsed.changes)}`);
|
||
assert.ok(progressChange.includes('50%'), `progress must reflect nested counts (min(3/5, 1/2)=50%); got: "${progressChange}"`);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-501-flat-phase-details-milestone-leak.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-501-flat-phase-details-milestone-leak (consolidation epic #1969 B2 #1971)", () => {
|
||
/**
|
||
* Bug #501: extractCurrentMilestone leaks prior-milestone phases when the
|
||
* ROADMAP uses a flat shared "## Phase Details" section.
|
||
*
|
||
* extractCurrentMilestone returns `preamble + currentSection`, where the
|
||
* preamble is everything before the first milestone heading (only <details>
|
||
* blocks stripped). A flat "## Phase Details" section listing every phase
|
||
* across all milestones therefore leaks its `### Phase N:` headings into the
|
||
* active-milestone scope, so getMilestonePhaseFilter / buildStateFrontmatter
|
||
* count the whole project instead of just the active milestone.
|
||
*
|
||
* Maintainer direction (triage of #501): fix in code AND make
|
||
* `validate consistency` milestone-aware so it does not flag shipped phase
|
||
* dirs as orphans once the scope is correctly narrowed.
|
||
*
|
||
* Layout under test (mirrors the real repro):
|
||
* # Roadmap
|
||
* ## Phase Details <- flat, BEFORE the first milestone heading
|
||
* ### Phase 1..3 <- shipped phases
|
||
* ## ✅ v2.0 <- shipped milestone
|
||
* ## 🚧 v3.0 (active)
|
||
* ### Phase 4..5 <- active-milestone phases
|
||
* STATE.md milestone: v3.0 → state json must report total_phases: 2.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
const ROADMAP = `# Roadmap
|
||
|
||
Project overview prose that legitimately lives before the milestones.
|
||
|
||
## Phase Details
|
||
|
||
### Phase 1: Shipped One
|
||
Did a thing.
|
||
|
||
### Phase 2: Shipped Two
|
||
Did another thing.
|
||
|
||
### Phase 3: Shipped Three
|
||
Did a third thing.
|
||
|
||
## ✅ v2.0: Foundation (shipped)
|
||
|
||
Summary of the shipped milestone.
|
||
|
||
## 🚧 v3.0: Active Milestone
|
||
|
||
### Phase 4: Active One
|
||
Doing a thing.
|
||
|
||
### Phase 5: Active Two
|
||
Doing another thing.
|
||
`;
|
||
|
||
const STATE = `---
|
||
gsd_state_version: 1.0
|
||
milestone: v3.0
|
||
milestone_name: Active Milestone
|
||
status: in_progress
|
||
progress:
|
||
total_phases: 2
|
||
completed_phases: 0
|
||
total_plans: 0
|
||
completed_plans: 0
|
||
percent: 0
|
||
---
|
||
|
||
# Project State
|
||
|
||
## Current Position
|
||
|
||
Phase: 4 (Active One)
|
||
`;
|
||
|
||
describe('flat "## Phase Details" milestone leak (#501)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
// All five phase dirs exist on disk (the flat layout retains shipped dirs).
|
||
const phaseDirs = ['01-shipped-one', '02-shipped-two', '03-shipped-three', '04-active-one', '05-active-two'];
|
||
for (const d of phaseDirs) {
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n', 'utf-8');
|
||
}
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state json counts only the active milestone phases, not the flat Phase Details list', () => {
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.equal(result.success, true, result.error || result.output);
|
||
const state = JSON.parse(result.output);
|
||
assert.equal(
|
||
state.progress.total_phases,
|
||
2,
|
||
`active milestone v3.0 has 2 phases (4,5); flat Phase Details (1-3) must not leak. Got total_phases=${state.progress.total_phases}`
|
||
);
|
||
});
|
||
|
||
test('validate consistency does not flag shipped phase dirs as not-in-ROADMAP', () => {
|
||
// Once milestone scope is correctly narrowed (Test A), the shipped phase
|
||
// dirs (1-3) are no longer in the SCOPED roadmap. They are, however, real
|
||
// phases listed in the FULL roadmap, so they must NOT be reported as
|
||
// "exists on disk but not in ROADMAP" orphans. (#501 — validate must be
|
||
// milestone-aware.)
|
||
const result = runGsdTools(['validate', 'consistency'], tmpDir);
|
||
const payload = JSON.parse(result.output);
|
||
const warnings = payload.warnings || [];
|
||
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w));
|
||
assert.deepEqual(
|
||
orphanWarnings,
|
||
[],
|
||
`shipped phase dirs (1-3) are in the full ROADMAP and must not be flagged as orphans. Got: ${JSON.stringify(orphanWarnings)}`
|
||
);
|
||
});
|
||
|
||
test('validate health (W007) does not flag shipped phase dirs as not-in-ROADMAP', () => {
|
||
// cmdValidateHealth's Check 8 has the same coupling: its W007 membership
|
||
// check compared active disk phases against the active-milestone scope.
|
||
// Shipped phase dirs in the active phases/ dir must be checked against the
|
||
// FULL roadmap so they are not false W007 orphans. (#501)
|
||
const result = runGsdTools(['validate', 'health'], tmpDir);
|
||
const payload = JSON.parse(result.output);
|
||
const warnings = payload.warnings || [];
|
||
const w007Orphans = warnings.filter(
|
||
(w) => w.code === 'W007' && /exists on disk but not in ROADMAP/i.test(w.message)
|
||
);
|
||
assert.deepEqual(
|
||
w007Orphans,
|
||
[],
|
||
`shipped phase dirs (1-3) must not produce W007. Got: ${JSON.stringify(w007Orphans.map((w) => w.message))}`
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-1967-cache-invalidation.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-1967-cache-invalidation (consolidation epic #1969 B3 #1972)", () => {
|
||
// allow-test-rule: source-text-is-the-product (see #1967)
|
||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||
// Per CONTRIBUTING.md exception matrix.
|
||
|
||
/**
|
||
* Regression tests for #1967 cache invalidation.
|
||
*
|
||
* The disk scan cache in buildStateFrontmatter must be invalidated on
|
||
* writeStateMd to prevent stale reads if multiple state-mutating
|
||
* operations occur within the same Node process. This matters for:
|
||
* - SDK callers that require() gsd-tools.cjs as a module
|
||
* - Future dispatcher extensions that handle compound operations
|
||
* - Tests that import state.cjs directly
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, describe, beforeEach, afterEach } = 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 state = require('../gsd-core/bin/lib/state.cjs');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
describe('buildStateFrontmatter cache invalidation (#1967)', () => {
|
||
let tmpDir;
|
||
let planningDir;
|
||
let phasesDir;
|
||
let statePath;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1967-cache-'));
|
||
planningDir = path.join(tmpDir, '.planning');
|
||
phasesDir = path.join(planningDir, 'phases');
|
||
fs.mkdirSync(phasesDir, { recursive: true });
|
||
|
||
// Create a minimal config and STATE.md
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ project_code: 'TEST' })
|
||
);
|
||
|
||
statePath = path.join(planningDir, 'STATE.md');
|
||
fs.writeFileSync(statePath, [
|
||
'# State',
|
||
'',
|
||
'**Current Phase:** 1',
|
||
'**Status:** executing',
|
||
'**Total Phases:** 2',
|
||
'',
|
||
].join('\n'));
|
||
|
||
// Start with one phase directory containing one PLAN
|
||
const phase1 = path.join(phasesDir, '01-foo');
|
||
fs.mkdirSync(phase1);
|
||
fs.writeFileSync(path.join(phase1, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan\n');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('writeStateMd invalidates cache so subsequent reads see new disk state', () => {
|
||
// First write — populates cache via buildStateFrontmatter
|
||
const content1 = fs.readFileSync(statePath, 'utf-8');
|
||
state.writeStateMd(statePath, content1, tmpDir);
|
||
|
||
// Create a NEW phase directory AFTER the first write
|
||
// Without cache invalidation, the second write would still see only 1 phase
|
||
const phase2 = path.join(phasesDir, '02-bar');
|
||
fs.mkdirSync(phase2);
|
||
fs.writeFileSync(path.join(phase2, '02-1-PLAN.md'), '---\nphase: 2\nplan: 1\n---\n# Plan\n');
|
||
fs.writeFileSync(path.join(phase2, '02-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\n');
|
||
|
||
// Second write in the SAME process — must see the new phase
|
||
const content2 = fs.readFileSync(statePath, 'utf-8');
|
||
state.writeStateMd(statePath, content2, tmpDir);
|
||
|
||
// Read back and parse frontmatter to verify it reflects 2 phases, not 1
|
||
const result = fs.readFileSync(statePath, 'utf-8');
|
||
const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||
assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd');
|
||
|
||
const fm = fmMatch[1];
|
||
// Should show 2 total phases (the new disk state), not 1 (stale cache)
|
||
const totalPhasesMatch = fm.match(/total_phases:\s*(\d+)/);
|
||
assert.ok(totalPhasesMatch, 'frontmatter should contain total_phases');
|
||
assert.strictEqual(
|
||
parseInt(totalPhasesMatch[1], 10),
|
||
2,
|
||
'total_phases should reflect new disk state (2), not stale cache (1)'
|
||
);
|
||
|
||
// Should show 1 completed phase (phase 2 has SUMMARY)
|
||
const completedMatch = fm.match(/completed_phases:\s*(\d+)/);
|
||
assert.ok(completedMatch, 'frontmatter should contain completed_phases');
|
||
assert.strictEqual(
|
||
parseInt(completedMatch[1], 10),
|
||
1,
|
||
'completed_phases should reflect new disk state (1 complete), not stale cache (0)'
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3127-state-begin-phase-idempotent.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3127-state-begin-phase-idempotent (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep (see #3127)
|
||
|
||
// Regression tests for bug #3127.
|
||
//
|
||
// state.begin-phase is non-idempotent: when execute-phase calls it on a phase
|
||
// that is already mid-flight (e.g. --wave N resume), the handler unconditionally
|
||
// overwrites execution-progress fields with stale values from the last plan-phase run:
|
||
// - stopped_at / Last Activity Description reset to "context gathered; ready for plan-phase"
|
||
// - Current Plan reset to 1 (from plan being executed, e.g. 3)
|
||
// - Plan: N of M body line reset to "Plan: 1 of M"
|
||
// - Last activity timestamp reverted to an older value
|
||
// - progress.percent may decrease
|
||
//
|
||
// Fix: read the current Status field before writing. If the phase is already
|
||
// "Executing Phase N", skip the execution-progress fields (Current Plan, plan body
|
||
// line, Last Activity Description) and only update fields safe to overwrite on
|
||
// resume (Last Activity date, Status if somehow wrong).
|
||
// A --force flag bypasses the guard for intentional full resets.
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
|
||
// Load the state.cjs module internals via the command router
|
||
function requireStateCjs() {
|
||
return require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state.cjs'));
|
||
}
|
||
|
||
function makeTempPlanning(stateContent) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3127-'));
|
||
const planningDir = path.join(dir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
|
||
return dir;
|
||
}
|
||
|
||
// A STATE.md that is mid-flight on Phase 5 (Plan 3 of 8 in progress)
|
||
const MID_FLIGHT_STATE = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 5
|
||
Current Phase Name: test-phase
|
||
Total Plans in Phase: 8
|
||
Current Plan: 3
|
||
Status: Executing Phase 5
|
||
|
||
## Current Position
|
||
|
||
Phase: 5 (test-phase) — EXECUTING
|
||
Plan: 3 of 8 (Plan 00 SHIPPED — wave 1 complete; Plan 01 SHIPPED; Plan 02 next)
|
||
Status: Executing Phase 5
|
||
Last activity: 2026-05-05 -- Plan 02 SHIPPED wave 2 GREEN
|
||
|
||
## Progress
|
||
|
||
progress:
|
||
total_phases: 10
|
||
completed_phases: 4
|
||
percent: 89
|
||
|
||
stopped_at: Phase 5 Plan 02 SHIPPED — Wave 2 GREEN detailed narrative here; ready for Plan 03
|
||
`;
|
||
|
||
// A STATE.md that is NOT yet executing (plan-phase just ran)
|
||
const PRE_EXECUTE_STATE = `# GSD State
|
||
|
||
## Configuration
|
||
Current Phase: 5
|
||
Current Phase Name: test-phase
|
||
Total Plans in Phase: 8
|
||
Current Plan: 1
|
||
Status: Ready to execute
|
||
|
||
## Current Position
|
||
|
||
Phase: 5 (test-phase) — READY
|
||
Plan: 1 of 8
|
||
Status: Ready to execute
|
||
Last activity: 2026-05-04 -- context gathered; ready for plan-phase
|
||
|
||
stopped_at: Phase 5 context gathered; ready for plan-phase
|
||
`;
|
||
|
||
describe('bug #3127: state.begin-phase idempotency guard', () => {
|
||
test('begin-phase on a mid-flight phase does not reset Current Plan', () => {
|
||
const stateModule = requireStateCjs();
|
||
const { cmdStateBeginPhase } = stateModule;
|
||
if (!cmdStateBeginPhase) {
|
||
// Skip if not exported — the guard may be inside a private function
|
||
return;
|
||
}
|
||
const dir = makeTempPlanning(MID_FLIGHT_STATE);
|
||
try {
|
||
cmdStateBeginPhase(dir, '5', 'test-phase', 8, false);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
// Current Plan must not have been reset to 1
|
||
const planMatch = after.match(/^Current Plan:\s*(\S+)/m);
|
||
if (planMatch) {
|
||
assert.notStrictEqual(planMatch[1], '1',
|
||
'begin-phase reset Current Plan to 1 on a mid-flight phase — idempotency guard not applied');
|
||
}
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('begin-phase on a mid-flight phase does not overwrite stopped_at narrative', () => {
|
||
const stateModule = requireStateCjs();
|
||
const { cmdStateBeginPhase } = stateModule;
|
||
if (!cmdStateBeginPhase) return;
|
||
const dir = makeTempPlanning(MID_FLIGHT_STATE);
|
||
try {
|
||
cmdStateBeginPhase(dir, '5', 'test-phase', 8, false);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
// The rich stopped_at narrative must be preserved
|
||
assert.ok(
|
||
after.includes('Plan 02 SHIPPED') || after.includes('Wave 2 GREEN'),
|
||
'begin-phase overwrote stopped_at narrative on a mid-flight phase',
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('begin-phase on a NOT-yet-executing phase sets Current Plan to 1 (normal path)', () => {
|
||
const stateModule = requireStateCjs();
|
||
const { cmdStateBeginPhase } = stateModule;
|
||
if (!cmdStateBeginPhase) return;
|
||
const dir = makeTempPlanning(PRE_EXECUTE_STATE);
|
||
try {
|
||
cmdStateBeginPhase(dir, '5', 'test-phase', 8, false);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
// Normal path: Current Plan should become 1 (or stay 1)
|
||
const planMatch = after.match(/^Current Plan:\s*(\S+)/m);
|
||
if (planMatch) {
|
||
assert.strictEqual(planMatch[1], '1',
|
||
'begin-phase should set Current Plan to 1 on a fresh phase');
|
||
}
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('begin-phase always updates Last Activity date (safe on resume, pinned via GSD_NOW_MS)', () => {
|
||
const stateModule = requireStateCjs();
|
||
const { cmdStateBeginPhase } = stateModule;
|
||
if (!cmdStateBeginPhase) return;
|
||
const dir = makeTempPlanning(MID_FLIGHT_STATE);
|
||
|
||
const PINNED_MS = Date.parse('2020-11-25T09:00:00.000Z');
|
||
const PINNED_DATE = '2020-11-25';
|
||
// Pin the in-process clock via env vars before calling the function directly.
|
||
const origTestMode = process.env.GSD_TEST_MODE;
|
||
const origNowMs = process.env.GSD_NOW_MS;
|
||
process.env.GSD_TEST_MODE = '1';
|
||
process.env.GSD_NOW_MS = String(PINNED_MS);
|
||
try {
|
||
cmdStateBeginPhase(dir, '5', 'test-phase', 8, false);
|
||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||
assert.ok(
|
||
after.includes(PINNED_DATE),
|
||
`begin-phase must update Last Activity date to the pinned date ${PINNED_DATE} even on resume (safe field)`,
|
||
);
|
||
} finally {
|
||
// Restore env vars before cleanup to avoid leaking state to other tests.
|
||
if (origTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||
else process.env.GSD_TEST_MODE = origTestMode;
|
||
if (origNowMs === undefined) delete process.env.GSD_NOW_MS;
|
||
else process.env.GSD_NOW_MS = origNowMs;
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:fix-1445-999x-backlog-excluded-from-total-phases (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression test for bug #1445:
|
||
* 999.x backlog phases must not be counted toward total_phases.
|
||
*
|
||
* Root cause:
|
||
* deriveProgressFromRoadmap (phase-lifecycle.cts) counted ALL data rows
|
||
* matching /^\|\s*\d+/ in the progress table, including 999.x backlog rows.
|
||
* Similarly, state.cts's roadmapPhaseCount loop (via extractCurrentMilestone)
|
||
* counted 999.x phase headings because it only checked /\d/.test(m[1]).
|
||
*
|
||
* Fix:
|
||
* Both sites now test /^999(?:\.|$)/.test(token) and skip matching rows.
|
||
* Mirrors the existing init.cts /^999(?:\.|$)/ filter.
|
||
*
|
||
* Scenarios:
|
||
* A. deriveProgressFromRoadmap with a progress table containing a 999.x row.
|
||
* B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount.
|
||
*
|
||
* Follow-up #1580: the same `^999` (and Phase 0) sentinel exclusion was missing
|
||
* in two more code paths — `milestone complete`'s unstarted-phase guard
|
||
* (src/milestone.cts) and `roadmap analyze`'s next_phase routing + phase_count
|
||
* (src/roadmap.cts). Scenarios C and D below cover those.
|
||
*
|
||
* C. milestone complete is NOT blocked by a Phase 999 backlog heading.
|
||
* D. roadmap analyze never routes next_phase to 999 / never counts it.
|
||
*/
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
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 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('999 exact (no dot) row is also excluded', () => {
|
||
const roadmap = [
|
||
'## Milestone v1.0: Test',
|
||
'',
|
||
'| Phase | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- |',
|
||
'| 1. Alpha | 1/1 | Complete | ✅ |',
|
||
'| 2. Beta | 1/1 | Complete | ✅ |',
|
||
'| 999 Backlog | 0/0 | Backlog | |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(
|
||
result.totalPhases,
|
||
2,
|
||
`total_phases must be 2 (not 3) — 999 row must be excluded. Got ${result.totalPhases}`,
|
||
);
|
||
assert.equal(
|
||
result.completedPhases,
|
||
2,
|
||
`completed_phases must be 2. Got ${result.completedPhases}`,
|
||
);
|
||
});
|
||
|
||
test('all-backlog table yields null total_phases (no real phases)', () => {
|
||
const roadmap = [
|
||
'## Milestone v1.0: Test',
|
||
'',
|
||
'| Phase | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- |',
|
||
'| 999.1 Future A | 0/0 | Backlog | |',
|
||
'| 999.2 Future B | 0/0 | Backlog | |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(
|
||
result.totalPhases,
|
||
null,
|
||
`total_phases must be null when the only rows are 999.x backlog. Got ${result.totalPhases}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #2137: header-driven parse handles the milestone-grouped (5-col) table ──
|
||
//
|
||
// Regression for #2137: deriveProgressFromRoadmap read the `## Progress` table
|
||
// with two 4-column-only regexes. Every project past its v1.0 milestone uses the
|
||
// 5-column milestone-grouped shape the same template ships, so the reader (which
|
||
// only understood 4 columns) returned { null, null, null } while the writer
|
||
// (cmdPhaseComplete, with its explicit `cells.length === 5` branch) happily wrote
|
||
// it — and phase.complete then silently skipped the STATE progress update. The
|
||
// fix reads columns by NAME, so both shapes parse identically. These tests would
|
||
// fail against the pre-fix 4-column regexes (which returned all-null for 5-col).
|
||
|
||
describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-grouped 5-column table', () => {
|
||
test("the template's own 5-column milestone-grouped Progress block parses non-null", () => {
|
||
// Byte-identical to gsd-core/templates/roadmap.md's "Milestone-Grouped
|
||
// Roadmap" Progress block — the exact shape that silently returned all-null.
|
||
const roadmap = [
|
||
'## Progress',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'|-------|-----------|----------------|--------|-----------|',
|
||
'| 1. Foundation | v1.0 | 3/3 | Complete | YYYY-MM-DD |',
|
||
'| 2. Features | v1.0 | 2/2 | Complete | YYYY-MM-DD |',
|
||
'| 5. Security | v1.1 | 0/2 | Not started | - |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(result.totalPhases, 3, `totalPhases must be 3 (5-col table must parse). Got ${result.totalPhases}`);
|
||
assert.equal(result.completedPhases, 2, `completedPhases must be 2 (Status is column 4 in the 5-col shape). Got ${result.completedPhases}`);
|
||
assert.equal(result.totalPlans, 7, `totalPlans must be 3+2+2=7 (Plans is column 3 in the 5-col shape). Got ${result.totalPlans}`);
|
||
});
|
||
|
||
test('the 4-column greenfield and 5-column milestone-grouped shapes derive the same progress', () => {
|
||
// The reader must agree with the writer on both shapes the template ships.
|
||
const fiveCol = [
|
||
'## Progress',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
|
||
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
|
||
].join('\n');
|
||
const fourCol = [
|
||
'## Progress',
|
||
'| Phase | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- |',
|
||
'| 1. Foundation | 3/3 | Complete | 2026-01-01 |',
|
||
'| 2. Features | 2/2 | Complete | 2026-01-02 |',
|
||
].join('\n');
|
||
|
||
assert.deepEqual(
|
||
deriveProgressFromRoadmap(fiveCol),
|
||
deriveProgressFromRoadmap(fourCol),
|
||
'the milestone-grouped and greenfield shapes must derive identical progress',
|
||
);
|
||
assert.deepEqual(deriveProgressFromRoadmap(fiveCol), {
|
||
completedPhases: 2,
|
||
totalPhases: 2,
|
||
totalPlans: 5,
|
||
});
|
||
});
|
||
|
||
test('999.x backlog rows stay excluded in the 5-column shape', () => {
|
||
const roadmap = [
|
||
'## Progress',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Alpha | v1.0 | 2/2 | Complete | 2026-01-01 |',
|
||
'| 2. Beta | v1.0 | 1/1 | Complete | 2026-01-02 |',
|
||
'| 999.1 Future | v2.0 | 0/0 | Backlog | - |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(result.totalPhases, 2, `999.1 backlog row must be excluded in the 5-col shape too. Got ${result.totalPhases}`);
|
||
assert.equal(result.completedPhases, 2, `completedPhases must be 2. Got ${result.completedPhases}`);
|
||
});
|
||
|
||
test('binds to the ## Progress table, not an earlier Phase/Status/Completed-shaped table', () => {
|
||
// A decoy table under a different heading shares the Phase/Status/Completed
|
||
// header shape. The reader must scope to ## Progress (mirroring the writer's
|
||
// #2012 scoping) rather than binding to the first matching table it sees.
|
||
const roadmap = [
|
||
'## Retrospective',
|
||
'',
|
||
'| Phase | Owner | Status | Completed |',
|
||
'| --- | --- | --- | --- |',
|
||
'| 1. Old | jo | Complete | 2025-01-01 |',
|
||
'',
|
||
'## Progress',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
|
||
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
|
||
'| 3. Security | v1.1 | 0/2 | Not started | - |',
|
||
'',
|
||
'## Next',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(result.totalPhases, 3, `must count the 3 rows of the ## Progress table, not the 1-row decoy. Got ${result.totalPhases}`);
|
||
assert.equal(result.completedPhases, 2, `must count Complete rows in ## Progress (2), not the decoy's 1. Got ${result.completedPhases}`);
|
||
assert.equal(result.totalPlans, 7, `must sum the ## Progress plans (3+2+2=7). Got ${result.totalPlans}`);
|
||
});
|
||
|
||
test('an h3 ### Progress decoy does not hijack the h2 ## Progress scope', () => {
|
||
// Heading detection must be line-anchored to h2: "### Progress".indexOf("## Progress")
|
||
// is 1, so a substring scan would start the slice inside the h3 subheading and
|
||
// miss the real table below.
|
||
const roadmap = [
|
||
'### Progress notes',
|
||
'',
|
||
'Some prose about progress, no table here.',
|
||
'',
|
||
'## Progress',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
|
||
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.equal(result.totalPhases, 2, `h2 ## Progress table must be found past the h3 decoy. Got ${result.totalPhases}`);
|
||
assert.equal(result.completedPhases, 2, `completedPhases must be 2. Got ${result.completedPhases}`);
|
||
});
|
||
|
||
// ── Boundary conditions (#2137 review) ──────────────────────────────────────
|
||
// The header-driven walk terminates at the first non-`|` line and skips the
|
||
// separator row, so these edges must not throw and must honour the "0 → null"
|
||
// contract that lets the consumer leave the existing STATE value untouched.
|
||
|
||
test('header + separator only (0 data rows) derives all-null', () => {
|
||
const roadmap = [
|
||
'## Progress',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.deepEqual(
|
||
result,
|
||
{ completedPhases: null, totalPhases: null, totalPlans: null },
|
||
`an empty table must report all-null (0 counts → null), got ${JSON.stringify(result)}`,
|
||
);
|
||
});
|
||
|
||
test('exactly one data row derives that single row', () => {
|
||
const roadmap = [
|
||
'## Progress',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Foundation | v1.0 | 4/4 | Complete | 2026-01-01 |',
|
||
].join('\n');
|
||
|
||
const result = deriveProgressFromRoadmap(roadmap);
|
||
assert.deepEqual(
|
||
result,
|
||
{ completedPhases: 1, totalPhases: 1, totalPlans: 4 },
|
||
`a single Complete row must derive {1,1,4}, got ${JSON.stringify(result)}`,
|
||
);
|
||
});
|
||
|
||
test('ragged rows (more/fewer cells than the header) are handled without throwing', () => {
|
||
// (#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',
|
||
'',
|
||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 1. Alpha | v1.0 | 2/2 | Complete | 2026-01-01 | stray-extra-column |', // 6 cells (extra)
|
||
'| 2. Beta | v1.0 |', // 2 cells (short: Plans/Status/Completed absent)
|
||
].join('\n');
|
||
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = deriveProgressFromRoadmap(roadmap);
|
||
}, 'ragged rows must not throw');
|
||
assert.deepEqual(
|
||
result,
|
||
{ completedPhases: null, totalPhases: null, totalPlans: null },
|
||
`a ragged-row table must fail loud to all-null (no throw), got ${JSON.stringify(result)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Scenario B: state json total_phases via roadmapPhaseCount ───────────────
|
||
|
||
describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => {
|
||
let tmpDir;
|
||
|
||
const ROADMAP = [
|
||
'## Milestone v1.0: Test Milestone',
|
||
'',
|
||
'### Phase 01: Alpha',
|
||
'**Goal:** first',
|
||
'',
|
||
'### Phase 02: Beta',
|
||
'**Goal:** second',
|
||
'',
|
||
'### Phase 03: Gamma',
|
||
'**Goal:** third',
|
||
'',
|
||
'### Phase 999.1: Backlog Item A',
|
||
'**Goal:** future idea, not counted',
|
||
'',
|
||
'### Phase 999.2: Backlog Item B',
|
||
'**Goal:** another future idea',
|
||
].join('\n');
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('bug-1445-');
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
[
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 1',
|
||
'Status: Executing Phase 1',
|
||
'Last Activity: 2026-01-01',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
|
||
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
}
|
||
// 999.x dirs should exist on disk but must not inflate total_phases
|
||
for (const d of ['999.1-backlog-a', '999.2-backlog-b']) {
|
||
fs.mkdirSync(path.join(planning, 'phases', d), { recursive: true });
|
||
}
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state json total_phases is 3, not 5 (999.x dirs and headings excluded)', () => {
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const state = JSON.parse(result.output);
|
||
assert.ok(state.progress, 'state json must return a progress block');
|
||
assert.equal(
|
||
state.progress.total_phases,
|
||
3,
|
||
`total_phases must be 3 (not 5). 999.x backlog phases must be excluded. Got ${state.progress.total_phases}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Scenario C: milestone complete not blocked by a 999 backlog heading ─────
|
||
|
||
describe('fix #1580 — milestone complete ignores the 999 backlog sentinel', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('fix-1580-mc-');
|
||
const planning = path.join(tmpDir, '.planning');
|
||
// One real, on-disk phase + a directory-less Phase 999 backlog heading.
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap v1.0',
|
||
'## v1.0 Milestone',
|
||
'## Phases',
|
||
'- [x] **Phase 1: Foundation**',
|
||
'## Phase Details',
|
||
'### Phase 1: Foundation',
|
||
'**Goal:** build it',
|
||
'### Phase 999: Backlog / Someday',
|
||
'**Goal:** deferred, never executed',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.0\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
|
||
'utf-8',
|
||
);
|
||
const dir = path.join(planning, 'phases', '01-foundation');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('completes WITHOUT --force despite a Phase 999 backlog heading', () => {
|
||
const result = runGsdTools(
|
||
['milestone', 'complete', 'v1.0', '--name', 'Regression'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(
|
||
result.success,
|
||
`milestone complete must not be blocked by the 999 sentinel; got error: ${result.error}`,
|
||
);
|
||
assert.ok(
|
||
!/Cannot mark milestone complete/.test(result.error || ''),
|
||
`the unstarted-phase guard must not fire on Phase 999. Got: ${result.error}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Scenario D: roadmap analyze never routes/ counts the 999 sentinel ────────
|
||
|
||
describe('fix #1580 — roadmap analyze excludes the 999 backlog sentinel', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('fix-1580-ra-');
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap v1.0',
|
||
'## v1.0 Milestone',
|
||
'## Phases',
|
||
'- [x] **Phase 1: Foundation**',
|
||
'## Phase Details',
|
||
'### Phase 1: Foundation',
|
||
'**Goal:** build it',
|
||
'### Phase 999: Backlog / Someday',
|
||
'**Goal:** deferred, never executed',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.0\n---\n# State\n`,
|
||
'utf-8',
|
||
);
|
||
const dir = path.join(planning, 'phases', '01-foundation');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('next_phase is never 999 and phase_count excludes the sentinel', () => {
|
||
const result = runGsdTools(['roadmap', 'analyze', '--raw'], tmpDir);
|
||
assert.ok(result.success, `roadmap analyze failed: ${result.error}`);
|
||
const analysis = JSON.parse(result.output);
|
||
assert.notEqual(
|
||
String(analysis.next_phase),
|
||
'999',
|
||
`next_phase must never route to the 999 backlog sentinel. Got ${analysis.next_phase}`,
|
||
);
|
||
assert.equal(
|
||
analysis.phase_count,
|
||
1,
|
||
`phase_count must exclude the 999 sentinel (expected 1). Got ${analysis.phase_count}`,
|
||
);
|
||
assert.ok(
|
||
!(analysis.phases || []).some(p => String(p.number) === '999'),
|
||
'the phases array must not include the 999 backlog sentinel',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/fix-1446-total-phases-corrects-downward.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:fix-1446-total-phases-corrects-downward (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression test for bug #1446:
|
||
* total_phases must correct downward when re-derived; shouldPreserveExistingProgress
|
||
* must NOT include total_phases in its ratchet check.
|
||
*
|
||
* Root cause:
|
||
* shouldPreserveExistingProgress (state-document.cts) returned true when
|
||
* existingProgress.total_phases > derivedProgress.total_phases, making the
|
||
* stored value sticky even when it was wrong (e.g. counted backlog phases).
|
||
*
|
||
* Fix:
|
||
* total_phases is removed from the "existing exceeds derived" check.
|
||
* Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
|
||
*
|
||
* Scenarios:
|
||
* A. shouldPreserveExistingProgress unit test — returns false when only total_phases differs.
|
||
* B. state sync re-derives a lower total_phases and writes the new value.
|
||
*/
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs');
|
||
|
||
// ─── Scenario A: unit test ───────────────────────────────────────────────────
|
||
|
||
describe('bug #1446 — shouldPreserveExistingProgress does not ratchet total_phases', () => {
|
||
test('existing total_phases:10 > derived total_phases:7 → returns false (no ratchet)', () => {
|
||
const existing = { total_phases: 10, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||
assert.equal(
|
||
shouldPreserveExistingProgress(existing, derived),
|
||
false,
|
||
'total_phases downward correction must NOT trigger shouldPreserveExistingProgress',
|
||
);
|
||
});
|
||
|
||
test('existing completed_phases:5 > derived completed_phases:2 → returns true (ratchet still active)', () => {
|
||
const existing = { total_phases: 7, completed_phases: 5, total_plans: 6, completed_plans: 3 };
|
||
const derived = { total_phases: 7, completed_phases: 2, total_plans: 6, completed_plans: 3 };
|
||
assert.equal(
|
||
shouldPreserveExistingProgress(existing, derived),
|
||
true,
|
||
'completed_phases ratchet must still work',
|
||
);
|
||
});
|
||
|
||
test('existing total_phases:10 > derived:7 AND completed_phases matches → false (total_phases alone does not preserve)', () => {
|
||
const existing = { total_phases: 10, completed_phases: 3 };
|
||
const derived = { total_phases: 7, completed_phases: 3 };
|
||
assert.equal(
|
||
shouldPreserveExistingProgress(existing, derived),
|
||
false,
|
||
'only-total_phases discrepancy must not trigger preservation',
|
||
);
|
||
});
|
||
|
||
test('all derived values equal existing → returns false', () => {
|
||
const existing = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||
assert.equal(shouldPreserveExistingProgress(existing, derived), false);
|
||
});
|
||
});
|
||
|
||
// ─── Scenario B: end-to-end state sync overwrites inflated total_phases ──────
|
||
|
||
describe('bug #1446 — state sync writes corrected (lower) total_phases', () => {
|
||
let tmpDir;
|
||
|
||
// ROADMAP has 3 real phases only (no 999.x).
|
||
const ROADMAP = [
|
||
'## Milestone v1.0: Test',
|
||
'',
|
||
'### Phase 01: Alpha',
|
||
'**Goal:** alpha',
|
||
'',
|
||
'### Phase 02: Beta',
|
||
'**Goal:** beta',
|
||
'',
|
||
'### Phase 03: Gamma',
|
||
'**Goal:** gamma',
|
||
].join('\n');
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('bug-1446-');
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||
|
||
// STATE.md has a stale inflated total_phases:10 in frontmatter.
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
[
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'status: executing',
|
||
'progress:',
|
||
' total_phases: 10',
|
||
' completed_phases: 2',
|
||
' total_plans: 6',
|
||
' completed_plans: 4',
|
||
' percent: 40',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 3',
|
||
'Status: Executing Phase 3',
|
||
'Last Activity: 2026-01-01',
|
||
'Progress: [████░░░░░░] 40%',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
|
||
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
// Mark 01 and 02 as complete (2 summaries)
|
||
if (d !== '03-gamma') {
|
||
fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8');
|
||
}
|
||
}
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state sync corrects total_phases from 10 to 3', () => {
|
||
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
const jsonResult = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const state = JSON.parse(jsonResult.output);
|
||
|
||
assert.ok(state.progress, 'state json must return a progress block');
|
||
assert.equal(
|
||
state.progress.total_phases,
|
||
3,
|
||
`total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`,
|
||
);
|
||
// completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2
|
||
assert.ok(
|
||
state.progress.completed_phases >= 2,
|
||
`completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:fix-1514-retired-phase-excluded-from-total-phases (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression test for bug #1514:
|
||
* A retired/folded phase (struck through in ROADMAP, marked `[x]`, with a
|
||
* directory but no completion artifact) must NOT be counted in
|
||
* progress.total_phases. Otherwise it inflates the denominator without ever
|
||
* satisfying the numerator (no SUMMARY → never "completed"), freezing a
|
||
* fully-shipped milestone below 100%.
|
||
*
|
||
* Root cause:
|
||
* buildStateFrontmatter (state.cts) derived total_phases from
|
||
* max(phaseDirs.length, roadmapPhaseCount) — both of which counted the
|
||
* retired phase (its directory and its `### Phase NN:` heading) — while
|
||
* completed_phases came from a disk SUMMARY scan that the retired phase
|
||
* can never satisfy. Same counting family as #549 / #500 / #1445.
|
||
*
|
||
* Fix:
|
||
* buildStateFrontmatter now extracts retired phase numbers from the GFM
|
||
* strikethrough in the current-milestone ROADMAP scope and excludes them
|
||
* from BOTH the disk phase-dir set and the heading count, so a retired
|
||
* phase counts toward neither denominator nor numerator.
|
||
*
|
||
* Why integration (state json) not a unit test: the bug only manifests in the
|
||
* assembled progress block a shipped milestone actually writes to STATE.md, so
|
||
* the test reproduces that artifact rather than a helper in isolation.
|
||
*/
|
||
|
||
const { describe, test, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const fc = require('./helpers/fast-check-setup.cjs');
|
||
const { _extractRetiredPhaseNumbers } = require('../gsd-core/bin/lib/state.cjs');
|
||
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||
|
||
// Six phases, all shipped, except Phase 04 which is retired/folded into 05.
|
||
// Phases 01-03,05,06 have PLAN+SUMMARY (complete); Phase 04 keeps a directory
|
||
// but no work (retired). `complete` flags which dirs get PLAN+SUMMARY.
|
||
function seedProject(prefix, roadmap, completeDirs) {
|
||
const tmpDir = createTempProject(prefix);
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
[
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 6',
|
||
'Status: shipped',
|
||
'Last Activity: 2026-06-01',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
const allDirs = ['01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '06-zeta'];
|
||
for (const d of allDirs) {
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
if (completeDirs.includes(d)) {
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||
}
|
||
}
|
||
return tmpDir;
|
||
}
|
||
|
||
const PHASE_DETAILS = [
|
||
'### Phase 01: Alpha', '**Goal:** a', '',
|
||
'### Phase 02: Beta', '**Goal:** b', '',
|
||
'### Phase 03: Gamma', '**Goal:** c', '',
|
||
'### Phase 04: Delta', '**Goal:** GOAL_04', '',
|
||
'### Phase 05: Epsilon', '**Goal:** e', '',
|
||
'### Phase 06: Zeta', '**Goal:** f',
|
||
];
|
||
|
||
function roadmap(checklist04, goal04) {
|
||
return [
|
||
'## Milestone v1.0: Repro',
|
||
'',
|
||
'### Phases',
|
||
'- [x] **Phase 01: Alpha** — done',
|
||
'- [x] **Phase 02: Beta** — done',
|
||
'- [x] **Phase 03: Gamma** — done',
|
||
checklist04,
|
||
'- [x] **Phase 05: Epsilon** — done',
|
||
'- [x] **Phase 06: Zeta** — done',
|
||
'',
|
||
...PHASE_DETAILS.map((l) => (l === '**Goal:** GOAL_04' ? `**Goal:** ${goal04}` : l)),
|
||
].join('\n');
|
||
}
|
||
|
||
const ALL_COMPLETE = ['01-alpha', '02-beta', '03-gamma', '05-epsilon', '06-zeta'];
|
||
|
||
describe('bug #1514 — retired/folded phase excluded from progress.total_phases', () => {
|
||
let tmpDir;
|
||
afterEach(() => {
|
||
if (tmpDir) cleanup(tmpDir);
|
||
tmpDir = undefined;
|
||
});
|
||
|
||
test('struck `[x] ~~Phase 04~~ — folded into Phase 05` → 5/5, percent 100 (not 5/6, 83)', () => {
|
||
const rm = roadmap(
|
||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||
'folded into Phase 05',
|
||
);
|
||
tmpDir = seedProject('bug-1514-a-', rm, ALL_COMPLETE);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 5, `total_phases must exclude the retired phase. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`);
|
||
assert.equal(progress.percent, 100, `shipped milestone must reach 100%. Got ${progress.percent}`);
|
||
});
|
||
|
||
test('fold TARGET is not retired: a struck goal line `~~folded into Phase 05~~` must not drop Phase 05', () => {
|
||
// Phase 04 retired via checklist; Phase 04 *goal* also struck and mentions
|
||
// the fold target. The target (Phase 05) must remain a counted phase.
|
||
const rm = roadmap(
|
||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||
'~~folded into Phase 05; retired~~',
|
||
);
|
||
tmpDir = seedProject('bug-1514-b-', rm, ALL_COMPLETE);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 5, `only Phase 04 is retired; Phase 05 must still count. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`);
|
||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||
});
|
||
|
||
test('regression: no strikethrough → all 6 phases counted (6/6, 100)', () => {
|
||
const rm = roadmap('- [x] **Phase 04: Delta** — done', 'd');
|
||
tmpDir = seedProject('bug-1514-c-', rm, [...ALL_COMPLETE, '04-delta']);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 6, `no retired phase: all 6 counted. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 6, `Got ${progress.completed_phases}`);
|
||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||
});
|
||
|
||
// `state sync --verify` is the SECOND counting path (cmdStateSync). Before the
|
||
// fix it re-derived the same inflated denominator and reported "no drift",
|
||
// so a manual STATE edit was the only recourse (#1514). It must now agree
|
||
// with state json and drive the stuck 83% Progress field to 100%.
|
||
test('state sync --verify drives a stuck 83% Progress to 100% (cmdStateSync path)', () => {
|
||
const rm = roadmap(
|
||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||
'folded into Phase 05',
|
||
);
|
||
tmpDir = seedProject('bug-1514-sync-', rm, ALL_COMPLETE);
|
||
// Seed a stuck Progress line that the inflated denominator would "agree" with.
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.appendFileSync(statePath, '\nProgress: [████████░░] 83%\n', 'utf-8');
|
||
const result = runGsdTools(['state', 'sync', '--verify'], tmpDir);
|
||
assert.ok(result.success, `state sync --verify failed: ${result.error}`);
|
||
const { changes } = JSON.parse(result.output);
|
||
const progressChange = (changes || []).find((c) => /Progress:/.test(c));
|
||
assert.ok(progressChange, `expected a Progress drift, got changes: ${JSON.stringify(changes)}`);
|
||
assert.match(progressChange, /-> .*100%/, `sync must want 100%, got: ${progressChange}`);
|
||
});
|
||
});
|
||
|
||
// ─── Generic seeder for non-canonical phase shapes ──────────────────────────
|
||
|
||
/**
|
||
* Seed a project from explicit phase specs so project-code, decimal,
|
||
* no-directory, and shipped-then-retired shapes can be exercised.
|
||
* spec: { id, retired?, dir?, shipped? }
|
||
* id — ROADMAP phase id (e.g. '04', '05.1', 'PROJ-42')
|
||
* retired — strike the checklist entry (folded/retired)
|
||
* dir — directory name to create (omit → no directory)
|
||
* shipped — write PLAN+SUMMARY into the directory (complete)
|
||
*/
|
||
function seedFromSpecs(prefix, specs) {
|
||
const tmpDir = createTempProject(prefix);
|
||
const planning = path.join(tmpDir, '.planning');
|
||
const checklist = specs.map((s) =>
|
||
s.retired
|
||
? `- [x] ~~**Phase ${s.id}: P${s.id}**~~ — retired`
|
||
: `- [x] **Phase ${s.id}: P${s.id}** — done`,
|
||
);
|
||
const details = specs.flatMap((s) => [`### Phase ${s.id}: P${s.id}`, '**Goal:** g', '']);
|
||
const roadmapText = ['## Milestone v1.0: Specs', '', '### Phases', ...checklist, '', ...details].join('\n');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 1'].join('\n'),
|
||
'utf-8',
|
||
);
|
||
for (const s of specs) {
|
||
if (!s.dir) continue;
|
||
const dir = path.join(planning, 'phases', s.dir);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
if (s.shipped) {
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||
}
|
||
}
|
||
return tmpDir;
|
||
}
|
||
|
||
describe('bug #1514 — retired exclusion across phase shapes', () => {
|
||
let tmpDir;
|
||
afterEach(() => {
|
||
if (tmpDir) cleanup(tmpDir);
|
||
tmpDir = undefined;
|
||
});
|
||
|
||
test('project-code retired phase is dropped from the denominator (Phase PROJ-42)', () => {
|
||
// Project-code dirs are not milestone-mapped for completion counts (a
|
||
// separate pre-existing limitation), so assert only the total_phases
|
||
// denominator, which #1514 governs: the struck PROJ-42 heading must not
|
||
// be counted, while PROJ-41 / PROJ-43 still are.
|
||
tmpDir = seedFromSpecs('bug-1514-pc-', [
|
||
{ id: 'PROJ-41', dir: 'PROJ-41-a', shipped: true },
|
||
{ id: 'PROJ-42', retired: true, dir: 'PROJ-42-d' },
|
||
{ id: 'PROJ-43', dir: 'PROJ-43-c', shipped: true },
|
||
]);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 2, `retired project-code phase must be excluded. Got ${progress.total_phases}`);
|
||
});
|
||
|
||
test('decimal, multiple, shipped-then-retired, and no-directory retired phases all excluded', () => {
|
||
// Retired: 02 (executed → has SUMMARY, then folded), 04 (no work),
|
||
// 05.1 (decimal, no directory at all). Live: 01, 03, 06.
|
||
tmpDir = seedFromSpecs('bug-1514-multi-', [
|
||
{ id: '01', dir: '01-a', shipped: true },
|
||
{ id: '02', retired: true, dir: '02-b', shipped: true },
|
||
{ id: '03', dir: '03-c', shipped: true },
|
||
{ id: '04', retired: true, dir: '04-d' },
|
||
{ id: '05.1', retired: true },
|
||
{ id: '06', dir: '06-f', shipped: true },
|
||
]);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 3, `3 retired of 6 → total 3. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 3, `live phases 01/03/06 complete. Got ${progress.completed_phases}`);
|
||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||
});
|
||
|
||
test('boundary: every phase retired (k === n) → total_phases 0', () => {
|
||
tmpDir = seedFromSpecs('bug-1514-all-', [
|
||
{ id: '01', retired: true, dir: '01-a' },
|
||
{ id: '02', retired: true, dir: '02-b' },
|
||
{ id: '03', retired: true, dir: '03-c' },
|
||
]);
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 0, `all phases retired → denominator 0. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 0, `Got ${progress.completed_phases}`);
|
||
});
|
||
|
||
test('strikethrough in a non-checklist/heading line (a goal) does NOT retire that phase', () => {
|
||
// Detection is scoped to checklist/heading lines, so a struck GOAL line
|
||
// that begins with a phase reference must not retire it.
|
||
const tmp = createTempProject('bug-1514-prose-');
|
||
const planning = path.join(tmp, '.planning');
|
||
const roadmapText = [
|
||
'## Milestone v1.0: Prose',
|
||
'',
|
||
'### Phases',
|
||
'- [x] **Phase 01: A** — done',
|
||
'- [x] **Phase 02: B** — done',
|
||
'- [x] **Phase 03: C** — done',
|
||
'',
|
||
'### Phase 01: A', '**Goal:** g',
|
||
'### Phase 02: B', '**Goal:** ~~Phase 02 was renamed from an earlier plan~~',
|
||
'### Phase 03: C', '**Goal:** g',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 3'].join('\n'),
|
||
'utf-8',
|
||
);
|
||
for (const d of ['01-a', '02-b', '03-c']) {
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||
}
|
||
tmpDir = tmp;
|
||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||
const { progress } = JSON.parse(result.output);
|
||
assert.equal(progress.total_phases, 3, `struck prose in a goal line must not retire Phase 02. Got ${progress.total_phases}`);
|
||
assert.equal(progress.completed_phases, 3, `Got ${progress.completed_phases}`);
|
||
});
|
||
});
|
||
|
||
// ─── Property: the strikethrough parser extracts exactly the struck set ──────
|
||
|
||
// extractRetiredPhaseNumbers is the parsing/transformation core of the fix, so
|
||
// per RULESET.TESTS.property-based-testing it carries a fast-check property:
|
||
// for a roadmap with k of n checklist phases struck, the parser must return
|
||
// exactly the canonical keys of those k phases — no more, no fewer — across
|
||
// randomized phase counts and numeric/zero-padded/project-code ID forms. This
|
||
// underpins the `total_phases === n - k` guarantee the integration tests assert.
|
||
describe('bug #1514 — extractRetiredPhaseNumbers property: returns exactly the struck set', () => {
|
||
const idForm = (num, form) =>
|
||
form === 'padded' ? String(num).padStart(2, '0')
|
||
: form === 'project' ? `PROJ-${num}`
|
||
: String(num);
|
||
const keyOf = (num, form) => normalizePhaseName(idForm(num, form)).toUpperCase();
|
||
|
||
test('k-of-n struck phases → exactly k canonical keys, for any n/form', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
// Distinct phase numbers so canonical keys don't collide within a run.
|
||
fc.uniqueArray(fc.integer({ min: 1, max: 98 }), { minLength: 1, maxLength: 10 }),
|
||
fc.array(fc.boolean(), { minLength: 1, maxLength: 10 }),
|
||
fc.constantFrom('plain', 'padded', 'project'),
|
||
(nums, flagsRaw, form) => {
|
||
const lines = ['## Milestone v1.0: M', '', '### Phases'];
|
||
const struck = [];
|
||
nums.forEach((num, i) => {
|
||
const id = idForm(num, form);
|
||
if (flagsRaw[i]) {
|
||
lines.push(`- [x] ~~**Phase ${id}: P${num}**~~ — folded; retired`);
|
||
struck.push(num);
|
||
} else {
|
||
lines.push(`- [x] **Phase ${id}: P${num}** — done`);
|
||
}
|
||
});
|
||
|
||
const got = _extractRetiredPhaseNumbers(lines.join('\n'));
|
||
const expected = new Set(struck.map((num) => keyOf(num, form)));
|
||
|
||
assert.equal(got.size, expected.size, `size: got ${got.size}, expected ${expected.size}`);
|
||
for (const k of expected) assert.ok(got.has(k), `missing struck key ${k}`);
|
||
for (const k of got) assert.ok(expected.has(k), `extra (non-struck) key ${k}`);
|
||
},
|
||
),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|