Files
msd-core/tests/state-document.test.cjs
Tom Boucher c99d7bb2be test(#4522): migrate core CLI/domain state batch to named timeout constants (#4662)
Batch 11 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/state-document.test.cjs, tests/phase.test.cjs, tests/commands.test.cjs,
tests/pattern.test.cjs, tests/adr-612-bracket-coherence.test.cjs,
tests/adr-612-bracket-read-tolerance.test.cjs, tests/milestone-lock.test.cjs,
tests/init.test.cjs, tests/state-todos-render.test.cjs,
tests/quick-batch.test.cjs, tests/graphify.test.cjs, and
tests/effort-surface-axis.test.cjs with a named constant, per
eslint-rules/no-adhoc-timeout-literal.cjs. Removes these 12 files from the
rule's allowlist.

Ground truth via eslint found 25 sites, not the issue's stated 24 (phase.test.cjs
has 5, not 4) -- disclosed in the PR body.

Reuses PROBE_TIMEOUT_MS, GIT_TIMEOUT_MS, and LOOP_HOOK_POINT_CLI_TIMEOUT_MS
across 8 files. Adds two new shared constants to tests/helpers/timeouts.cjs
(each independently arrived at by 2 files in this batch, crossing the
promotion bar): PATHOLOGICAL_INPUT_TEST_TIMEOUT_MS (node:test's own per-test
timeout option, not a subprocess bound) and GSD_TOOLS_CLI_MODERATE_TIMEOUT_MS
(a single gsd-tools.cjs CLI subcommand spawn, distinct tier from
PROBE_TIMEOUT_MS/LOOP_HOOK_POINT_CLI_TIMEOUT_MS). Adds 3 file-local constants
for values used by only 1 file in this batch. No src/bin file touched, no
numeric value changed anywhere.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:12:31 -04:00

2565 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* state-document.test.cjs
*
* Characterization tests for the STATE.md pipe-table branch of
* stateReplaceField / stateExtractField / stateReplaceFieldWithFallback
* (issue #2880, ADR-2143 §3/§4). These lock byte-identical behaviour across
* the migration of the table branch off a hand-rolled whole-document regex
* onto a line-scan + byte-range splice (see gsd-core/bin/lib/state-document.cjs
* locateFieldRow). Any future re-implementation of the table branch must keep
* every assertion below true.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
stateReplaceField,
stateExtractField,
stateReplaceFieldWithFallback,
} = require('../gsd-core/bin/lib/state-document.cjs');
describe('stateReplaceField — table branch (characterization, #2880)', () => {
test('replaces a two-cell row in place', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('returns null for a three-cell row', () => {
const input = '| Current Phase | 3 | x |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('matches the field name case-insensitively and preserves its original casing', () => {
const input = '| current phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| current phase | 7 |');
});
test('replaces a row that has a header and delimiter above it', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |'].join('\n');
const expected = ['| F | V |', '| --- | --- |', '| Current Phase | 7 |'].join('\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('replaces a header-less legacy row', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('replaces only the first of two rows naming the same field', () => {
const input = ['| Current Phase | 3 |', '| Current Phase | 9 |'].join('\n');
const expected = ['| Current Phase | 7 |', '| Current Phase | 9 |'].join('\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('returns null when the value cell contains a pipe', () => {
const input = '| Current Phase | a|b |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('inserts before the closing pipe when the value cell is all whitespace', () => {
const input = '| Current Phase | |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7|');
});
test('never treats a delimiter row as a field', () => {
const input = '| --- | --- |';
const result = stateReplaceField(input, '---', 7);
assert.equal(result, null);
});
test('ignores an indented row', () => {
const input = ' | Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('inserts a dollar-sign pattern verbatim', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', '$&X');
assert.equal(result, '| Current Phase | $&X |');
});
test("preserves the row's exact interior padding", () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('returns null when the field is absent', () => {
const input = '| Other | 3 |';
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, null);
});
test('returns null for empty content', () => {
const result = stateReplaceField('', 'Current Phase', 7);
assert.equal(result, null);
});
});
describe('stateReplaceField — CRLF (#2880)', () => {
test('preserves CRLF line endings byte-for-byte', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |', ''].join('\r\n');
const expected = ['| F | V |', '| --- | --- |', '| Current Phase | 7 |', ''].join('\r\n');
const result = stateReplaceField(input, 'Current Phase', 7);
assert.equal(result, expected);
});
test('replaces a bold field on a CRLF document', () => {
const input = ['**Status:** old', ''].join('\r\n');
const expected = ['**Status:** new', ''].join('\r\n');
const result = stateReplaceField(input, 'Status', 'new');
assert.equal(result, expected);
});
test('replaces a row terminated by a lone carriage return', () => {
const input = ['| Phase | 3 |', '| Other | 9 |'].join('\r');
const expected = ['| Phase | 5 |', '| Other | 9 |'].join('\r');
const result = stateReplaceField(input, 'Phase', 5);
assert.equal(result, expected);
});
});
// #4010: when a STATE.md body field is empty, the label-to-value gap `\s*` in
// stateReplaceField's bold/plain patterns crossed the newline and `(.*)` ate the
// following line, which the rebuild then discarded — silent data-loss. The fix
// confines the gap to same-line whitespace (`[ \t]*`, mirroring the already-
// correct read side at stateExtractField) and pins the separator to a single
// space when the label line had none (no glued `**Status:**value`). These assert
// the EXACT output so the glued shape cannot pass (ADR-3180 §7.7 same-line owner).
describe('stateReplaceField — empty field preserves the following line (#4010)', () => {
test('bold empty field: value lands on the label line, next line survives, no glue', () => {
const input = '**Status:**\n**Current Plan:** 2 of 5';
const expected = '**Status:** Executing Phase 5\n**Current Plan:** 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
test('plain empty field: value lands on the label line, next line survives, no glue', () => {
const input = 'Status:\nCurrent Plan: 2 of 5';
const expected = 'Status: Executing Phase 5\nCurrent Plan: 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
test('bold empty field on a CRLF document preserves the following line', () => {
const input = '**Status:**\r\n**Current Plan:** 2 of 5';
const expected = '**Status:** Executing Phase 5\r\n**Current Plan:** 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
test('plain empty field on a CRLF document preserves the following line', () => {
const input = 'Status:\r\nCurrent Plan: 2 of 5';
const expected = 'Status: Executing Phase 5\r\nCurrent Plan: 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
test('non-empty bold field stays byte-identical to prior behaviour', () => {
const input = '**Status:** Planning\n**Current Plan:** 2 of 5';
const expected = '**Status:** Executing Phase 5\n**Current Plan:** 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
// Boundary: a NON-EMPTY field written with no label-to-value separator at all
// (a hand-edited `**Status:**Planning`). The narrowed `[ \t]*` gap captures
// nothing, so joinFieldReplacement synthesizes a single space — an intentional
// normalization, NOT byte-identical to the pre-fix glued output. Pins that the
// following line still survives and exactly one space is inserted, matching the
// scoped byte-identity claim in joinFieldReplacement's JSDoc. (#4010)
test('non-empty bold field with no separator gains one inserted space, next line survives', () => {
const input = '**Status:**Planning\n**Current Plan:** 2 of 5';
const expected = '**Status:** Executing Phase 5\n**Current Plan:** 2 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
test('E2E: transitionCore update of an empty field preserves the next line (ADR-3180 Decision 4(c))', () => {
const { transitionCore } = require('../gsd-core/bin/lib/state-transition.cjs');
const content = '## Current Position\n\n**Status:**\n**Current Plan:** 2 of 5';
const result = transitionCore(content, { kind: 'update', field: 'Status', value: 'Executing Phase 5' });
assert.equal(result.content, '## Current Position\n\n**Status:** Executing Phase 5\n**Current Plan:** 2 of 5');
assert.deepEqual(result.updated, ['Status']);
});
// Boundary: an empty field that is the LAST line (no following line at all).
// `.*` matches the empty tail with no newline to cross, so the value simply
// lands on the label line — nothing beyond it to preserve or corrupt.
test('bold empty field at end-of-document (no following line) gets the value on its line', () => {
assert.equal(stateReplaceField('**Status:**', 'Status', 'Executing Phase 5'), '**Status:** Executing Phase 5');
});
test('plain empty field at end-of-document (no following line) gets the value on its line', () => {
assert.equal(stateReplaceField('Status:', 'Status', 'Executing Phase 5'), 'Status: Executing Phase 5');
});
// Boundary: two consecutive empty fields. Only the targeted label line takes
// the value; the adjacent empty field's line must survive untouched (the read
// side matches the FIRST label, so the second empty field cannot be swallowed).
test('two consecutive empty fields: only the target is filled, the other empty field survives', () => {
const input = '**Status:**\n**Current Plan:**\n**Progress:** 3 of 5';
const expected = '**Status:** Executing Phase 5\n**Current Plan:**\n**Progress:** 3 of 5';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 5'), expected);
});
// Boundary: an EMPTY new value on an empty field. joinFieldReplacement's
// needsSeparator branch must NOT synthesize a trailing space (value.length is
// 0), so the label line is left as the bare `**Status:**` and the following
// line is still preserved — no dangling separator, no line eaten.
test('empty new value on an empty field leaves a bare label and preserves the following line', () => {
const input = '**Status:**\n**Current Plan:** 2 of 5';
const expected = '**Status:**\n**Current Plan:** 2 of 5';
assert.equal(stateReplaceField(input, 'Status', ''), expected);
});
});
// #4243: the bold branch of stateReplaceField was UNANCHORED
// (`(\*\*Field:\*\*[ \t]*)(.*)` with no ^ and no /m), so a bold label quoted
// MID-SENTENCE inside prose — the issue's `**Status:**` inside an Accumulated
// Context bullet — captured the rewrite and destroyed the rest of the line,
// silently, while the real field went stale or was updated elsewhere. The fix
// anchors the bold form to line start with same-line leading whitespace
// (`^([ \t]*\*\*Field:\*\*[ \t]*)`, 'im'), reusing #4010's same-line
// confinement idiom and #4186's recognition-by-anchoring discipline. These rows
// pin the corruption shapes; rows further down pin the negative space
// (legitimate line-start bold updates are byte-identical, branch order and
// first-occurrence-wins unchanged).
describe('stateReplaceField — anchored bold form leaves prose lookalikes untouched (#4243)', () => {
// The issue's verbatim prose line: a bold label quoted for documentation
// purposes inside a bullet, with the real field in the plain template form.
const ISSUE_PROSE_LINE =
'- [Phase 170]: archived files gained a `**Status:**Ready to execute` marker. Must not change.';
// ROW 1 — the failing-first regression from the issue. The lookalike must
// survive byte-identically and the REAL plain field must take the update.
test('issue repro: mid-sentence **Status:** lookalike survives, real plain field updates', () => {
const input = [
'## Current Position',
'',
'Phase: 5 of 9',
'Plan: 2 of 6',
'Status: Ready to execute',
'Last activity: 2026-08-01 — did a thing',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
ISSUE_PROSE_LINE,
'',
].join('\n');
const result = stateReplaceField(input, 'Status', 'Executing Phase 901');
assert.notEqual(result, null, 'the real plain field must still match');
assert.ok(
result.includes(ISSUE_PROSE_LINE),
`prose lookalike must survive byte-identically, got:\n${result}`,
);
assert.ok(
/^Status: Executing Phase 901$/m.test(result),
'the real plain Status line must take the update',
);
assert.ok(
!result.includes('Executing Phase 901` marker'),
'the rewrite must not bleed into the prose occurrence',
);
});
test('lookalike ordered BEFORE the real bold field: prose survives, bold field updates', () => {
const input = [
'## Accumulated Context',
'',
ISSUE_PROSE_LINE,
'',
'## Current Position',
'',
'**Status:** Ready to execute',
'',
].join('\n');
const result = stateReplaceField(input, 'Status', 'Executing Phase 901');
assert.notEqual(result, null);
assert.ok(result.includes(ISSUE_PROSE_LINE), `prose lookalike must survive, got:\n${result}`);
assert.ok(
/^\*\*Status:\*\* Executing Phase 901$/m.test(result),
'the real line-start bold field must take the update',
);
});
test('mid-word lookalike with no real field: returns null (honest absence), never a rewrite', () => {
const input = 'Prose mentions text**Status:**tail mid-word and nothing else.';
assert.equal(stateReplaceField(input, 'Status', 'Executing Phase 901'), null);
});
// A list-item bold label (`- **Status:** value`) is prose-shaped for the
// writer: no STATE.md writer emits body fields as list items, and treating
// a bullet as a field write target is exactly the #4243 corruption class.
// The read side's own vocabulary (bold anywhere) is untouched; the writer
// reports honest absence instead.
test('list-item bold label is not a write target: returns null, bullet untouched', () => {
const input = '- **Status:** resolved in the archived review';
assert.equal(stateReplaceField(input, 'Status', 'new'), null);
});
// Every field the regex serves: a document whose ONLY occurrence of the
// label is a mid-sentence lookalike must yield null — no served field may
// be rewritten from prose.
test('mid-sentence lookalike yields null for every served field', () => {
const servedFields = [
'Status', 'Phase', 'Plan', 'Current Plan', 'Current Phase', 'Current Phase Name',
'Last Activity', 'Last Activity Description', 'Total Phases', 'Total Plans in Phase',
'Progress', 'Completed Phases', 'Stopped At',
];
for (const field of servedFields) {
const input = `Some prose sentence quoting a **${field}:** label mid-sentence, plus trailing words.`;
assert.equal(
stateReplaceField(input, field, 'NEW'),
null,
`mid-sentence **${field}:** lookalike must not match (got a rewrite)`,
);
}
});
test('lookalike plus real plain field: only the real plain line changes (representative fields)', () => {
const cases = [
{ field: 'Status', plain: 'Status: Ready to execute' },
{ field: 'Phase', plain: 'Phase: 5 of 9' },
{ field: 'Last Activity', plain: 'Last Activity: 2026-08-01 — did a thing' },
];
for (const { field, plain } of cases) {
const lookalike = `- notes: the **${field}:** label was archived here. Keep it.`;
const input = [plain, '', '## Accumulated Context', '', lookalike, ''].join('\n');
const result = stateReplaceField(input, field, 'NEW VALUE');
assert.notEqual(result, null, `${field}: real plain field must match`);
assert.ok(
result.includes(lookalike),
`${field}: lookalike line must survive byte-identically, got:\n${result}`,
);
}
});
// Negative space: an INDENTED line-start bold field is still a field (the
// doc's form ranking reads bold anywhere in the section; the writer keeps
// same-line indentation writable), and the indent is preserved.
test('indented line-start bold field still updates, indent preserved', () => {
const input = ' **Status:** old';
const result = stateReplaceField(input, 'Status', 'new');
assert.equal(result, ' **Status:** new');
});
// Negative space + fix-shape pin: leading blank lines before the label are
// NOT swallowed. The anchor's leading class is same-line whitespace only
// (`[ \t]*`, #4010's idiom); the issue's suggested `^\s*` variant would
// consume the newlines into the match and drop them on rebuild.
test('leading blank lines before a bold label survive byte-identically', () => {
const input = '\n\n**Status:** Ready';
const result = stateReplaceField(input, 'Status', 'Executing Phase 5');
assert.equal(result, '\n\n**Status:** Executing Phase 5');
});
test('CRLF document: lookalike survives with CRLF intact, real plain field updates', () => {
const input = [
'Status: Ready to execute',
'',
'## Accumulated Context',
'',
ISSUE_PROSE_LINE,
'',
].join('\r\n');
const result = stateReplaceField(input, 'Status', 'Executing Phase 901');
assert.notEqual(result, null);
assert.ok(result.includes(ISSUE_PROSE_LINE), `prose lookalike must survive, got:\n${result}`);
assert.ok(result.includes('\r\n'), 'CRLF endings must be preserved');
assert.ok(/^Status: Executing Phase 901\r?$/m.test(result), 'real plain field must update');
});
// Negative space: branch ORDER is unchanged — a line-start bold field still
// beats the plain form, and only the first bold occurrence is replaced.
test('line-start bold still beats the plain form (branch order unchanged)', () => {
const input = '**Status:** old bold\nStatus: old plain';
const result = stateReplaceField(input, 'Status', 'new');
assert.equal(result, '**Status:** new\nStatus: old plain');
});
test('two line-start bold occurrences: only the first is replaced', () => {
const input = '**Status:** first\n**Status:** second';
const result = stateReplaceField(input, 'Status', 'new');
assert.equal(result, '**Status:** new\n**Status:** second');
});
// #4010 same-line adjacency under the anchor: an empty bold field's value
// lands on its own line and the following line survives.
test('anchored bold branch keeps the #4010 empty-field boundary', () => {
const input = ' **Status:**\n **Current Plan:** 2 of 5';
const result = stateReplaceField(input, 'Status', 'Executing Phase 5');
assert.equal(result, ' **Status:** Executing Phase 5\n **Current Plan:** 2 of 5');
});
});
describe('stateExtractField (#2880)', () => {
test('extracts from a two-cell row', () => {
const input = '| Current Phase | 3 |';
assert.equal(stateExtractField(input, 'Current Phase'), '3');
});
test('extracts from a CRLF document', () => {
const input = ['| F | V |', '| --- | --- |', '| Current Phase | 3 |', ''].join('\r\n');
assert.equal(stateExtractField(input, 'Current Phase'), '3');
});
test('returns null when absent', () => {
const input = '| Other | 3 |';
assert.equal(stateExtractField(input, 'Current Phase'), null);
});
test('round-trip: extract after replace returns the new value', () => {
const input = '| Current Phase | 3 |';
const replaced = stateReplaceField(input, 'Current Phase', '7');
assert.equal(stateExtractField(replaced, 'Current Phase'), '7');
});
test('matches a row terminated by a lone carriage return', () => {
const input = ['| Phase | 3 |', '| Other | 9 |'].join('\r');
assert.equal(stateExtractField(input, 'Phase'), '3');
});
test('does not match when the field name has more padding than the cell', () => {
const input = '| Phase | 3 |';
assert.equal(stateExtractField(input, ' Phase '), null);
});
});
describe('stateReplaceFieldWithFallback (#2880)', () => {
test('uses the primary when present', () => {
const input = '| Current Phase | 3 |';
const result = stateReplaceFieldWithFallback(input, 'Current Phase', 'Phase', 7);
assert.equal(result, '| Current Phase | 7 |');
});
test('falls back to the secondary name when the primary is absent', () => {
const input = '| Phase | 3 |';
const result = stateReplaceFieldWithFallback(input, 'Current Phase', 'Phase', 7);
assert.equal(result, '| Phase | 7 |');
});
test('returns the content UNCHANGED (not null) when both are absent', () => {
const content = '| Other | 3 |';
const result = stateReplaceFieldWithFallback(content, 'Current Phase', 'Phase', 7);
assert.equal(result, content);
});
});
describe('property: bounded mutation (#2880, ADR-2143 §4)', () => {
test('replacing one row leaves every other line byte-identical', () => {
const safeValue = fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 '.split('')), {
minLength: 1,
maxLength: 8,
})
.map((chars) => chars.join('').trim() || 'x');
fc.assert(
fc.property(
fc.integer({ min: 1, max: 6 }).chain((n) =>
fc.record({
n: fc.constant(n),
values: fc.array(safeValue, { minLength: n, maxLength: n }),
targetIndex: fc.integer({ min: 0, max: n - 1 }),
newValue: safeValue,
}),
),
({ n, values, targetIndex, newValue }) => {
const fieldNames = Array.from({ length: n }, (_, i) => `Field${i}`);
const lines = fieldNames.map((name, i) => `| ${name} | ${values[i]} |`);
const doc = lines.join('\n');
const result = stateReplaceField(doc, fieldNames[targetIndex], newValue);
assert.notEqual(result, null);
const resultLines = result.split('\n');
assert.equal(resultLines.length, lines.length);
for (let i = 0; i < lines.length; i++) {
if (i === targetIndex) continue;
assert.equal(resultLines[i], lines[i]);
}
assert.equal(resultLines[targetIndex], `| ${fieldNames[targetIndex]} | ${newValue} |`);
},
),
{ seed: 20880, numRuns: 200 },
);
});
});
// The #4010 regression was, at heart, a line-boundary violation: replacing a
// field crossed a newline and destroyed an adjacent line. This property pins the
// invariant that guards it directly on the bold/plain branches (and the new
// joinFieldReplacement helper) this fix touches: for any field name, any values
// (including empty fields), and any new value, replacing one field changes ONLY
// that field's line and never alters the total line count. (#4010)
describe('property: bold/plain field replacement stays within its own line (#4010)', () => {
test('replacing a bold or plain field changes only the target line and never the line count', () => {
const safeValue = fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 '.split('')), {
minLength: 0,
maxLength: 8,
})
.map((chars) => chars.join('').replace(/^ +| +$/g, '')); // trim; may be ''
fc.assert(
fc.property(
fc.integer({ min: 1, max: 6 }).chain((n) =>
fc.record({
n: fc.constant(n),
bold: fc.boolean(),
values: fc.array(safeValue, { minLength: n, maxLength: n }),
targetIndex: fc.integer({ min: 0, max: n - 1 }),
newValue: safeValue,
}),
),
({ n, bold, values, targetIndex, newValue }) => {
// Distinct single-digit names (Field0..Field5) so no field label is a
// prefix of another, and values (alphanumeric+space) can never contain
// a `:`/`*`, so the only line matching the target pattern is its own.
const fieldNames = Array.from({ length: n }, (_, i) => `Field${i}`);
const lines = fieldNames.map((name, i) => {
const label = bold ? `**${name}:**` : `${name}:`;
return values[i] === '' ? label : `${label} ${values[i]}`;
});
const doc = lines.join('\n');
const result = stateReplaceField(doc, fieldNames[targetIndex], newValue);
assert.notEqual(result, null);
const resultLines = result.split('\n');
// Core #4010 guarantee: no line eaten, none added — neighbours survive.
assert.equal(resultLines.length, lines.length);
for (let i = 0; i < lines.length; i++) {
if (i === targetIndex) continue;
assert.equal(resultLines[i], lines[i]);
}
// The target line still opens with its label and gained no newline.
const label = bold ? `**${fieldNames[targetIndex]}:**` : `${fieldNames[targetIndex]}:`;
assert.ok(
resultLines[targetIndex].startsWith(label),
`target line lost its label: ${JSON.stringify(resultLines[targetIndex])}`,
);
},
),
{ seed: 40100, numRuns: 300 },
);
});
});
describe('property: fieldNameMatchesRawCell case-fold semantics, via stateExtractField (FIX A)', () => {
test('match verdict agrees with an explicit ECMAScript non-unicode Canonicalize reference predicate', () => {
// Independent, obviously-correct reference for "are these two code units
// the same character under a non-`u`-flag `/i` RegExp": NOT
// `.toLowerCase()`, which incorrectly folds some non-ASCII characters
// (e.g. KELVIN SIGN U+212A) onto their ASCII counterparts ("k"), and
// also incorrectly folds multi-character uppercase mappings (e.g. "ß"
// toUpperCase()'s "SS") onto a two-character string. Per ECMAScript's
// non-unicode Canonicalize, a fold is REJECTED (the original character
// is kept as-is) whenever `ch.toUpperCase()` is not exactly one
// character, OR the original is non-ASCII (>= 128) while the uppercased
// result is ASCII (< 128).
function canonChar(ch) {
const upper = ch.toUpperCase();
if (upper.length !== 1) return ch;
if (ch.charCodeAt(0) >= 128 && upper.charCodeAt(0) < 128) return ch;
return upper;
}
function canonStringEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (canonChar(a[i]) !== canonChar(b[i])) return false;
}
return true;
}
// Reference predicate for the whole field-name/cell match, stated
// independently of (and structured differently from) the scanner under
// test: `fieldName` matches `rawCell` iff `fieldName` occurs as a literal
// (Canonicalize-compared) substring of `rawCell` at SOME offset `j`, with
// everything BEFORE `j` and everything AFTER the occurrence consisting
// exclusively of ' '/'\t'. This is a full, unbounded substring scan with
// no "leading run" shortcut.
function referenceFieldMatchesCell(fieldName, rawCell) {
const n = fieldName.length;
for (let j = 0; j + n <= rawCell.length; j++) {
const before = rawCell.slice(0, j);
const after = rawCell.slice(j + n);
if (!/^[ \t]*$/.test(before)) continue;
if (!/^[ \t]*$/.test(after)) continue;
if (canonStringEqual(rawCell.slice(j, j + n), fieldName)) return true;
}
return false;
}
const padArb = fc.array(fc.constantFrom(' ', '\t'), { minLength: 0, maxLength: 3 }).map((chars) => chars.join(''));
// Deliberately includes the KELVIN SIGN (U+212A) and other non-ASCII
// characters whose `.toLowerCase()`/`.toUpperCase()` folds onto an ASCII
// character — exactly the class of character FIX A addresses.
const coreCharArb = fc.constantFrom('a', 'b', 'K', 'k', 'P', 'p', 'H', 'A', '\u212A', '\u1E9E', '\u00DF', '1', '_');
const coreArb = fc.array(coreCharArb, { minLength: 1, maxLength: 5 }).map((chars) => chars.join(''));
const fieldNameArb = fc
.record({ pre: padArb, core: coreArb, post: padArb })
.map(({ pre, core, post }) => pre + core + post);
fc.assert(
fc.property(
fieldNameArb,
fc.boolean(),
fc.constantFrom('same', 'upper', 'lower'),
padArb,
padArb,
coreArb,
(fieldName, deriveFromFieldName, caseMode, cellPre, cellPost, randomCore) => {
const fieldCore = fieldName.replace(/^[ \t]+|[ \t]+$/g, '');
let derivedCore = fieldCore;
if (caseMode === 'upper') derivedCore = fieldCore.toUpperCase();
else if (caseMode === 'lower') derivedCore = fieldCore.toLowerCase();
const cellCore = deriveFromFieldName ? derivedCore : randomCore;
const cellContent = cellPre + cellCore + cellPost;
// The template fixes exactly one literal space on each side of
// cellContent, so rawCell (as locateFieldRow computes it) is
// ` ${cellContent} ` byte-for-byte.
const doc = `| ${cellContent} | value |`;
const rawCell = ` ${cellContent} `;
const expected = referenceFieldMatchesCell(fieldName, rawCell);
const result = stateExtractField(doc, fieldName);
assert.equal(result, expected ? 'value' : null);
},
),
{ seed: 28801, numRuns: 300 },
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/issue-2828-flat-roadmap-total-phases.test.cjs (H3 Wave 7, #3339)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe('folded:issue-2828-flat-roadmap-total-phases', () => {
'use strict';
// Regression guard for #2828: on a flat unmilestoned roadmap (no versioned milestone
// heading), `state-snapshot`/`state record-session` reported progress.total_phases as
// the on-disk phase-dir count (1) instead of the authoritative roadmap count (6). The
// read-path disk-scan cache fell back to phaseDirs.length when milestoneBounded was
// false, even though roadmapPhaseCount (6) was correct for a flat roadmap (no sibling
// milestones to conflate). Fix: use roadmapPhaseCount as the floor when > 0.
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('#2828 — total_phases uses the roadmap count on a flat unmilestoned roadmap', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-2828-');
const planningDir = path.join(tmpDir, '.planning');
// Flat unmilestoned roadmap with 6 phases (no versioned milestone heading).
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: Foundation',
'### Phase 2: Core API',
'### Phase 3: UI Layer',
'### Phase 4: Integration',
'### Phase 5: Polish',
'### Phase 6: Release',
'',
].join('\n'),
);
// Only phase 1 has been discussed → 1 phase dir on disk.
const phaseDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# Phase 1 Context\n');
// Minimal STATE.md with a milestone set (so milestoneBounded is computed) but no
// versioned heading to bound it to → the flat-roadmap unbounded case.
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'---',
'status: executing',
'milestone: v1.0',
'milestone_name: milestone',
'---',
'',
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** In progress',
'',
].join('\n'),
);
});
afterEach(() => cleanup(tmpDir));
test('state sync writes progress.total_phases === 6 (roadmap count), not 1 (phase-dir count) (#2828)', () => {
// `state sync` derives progress.total_phases from the disk-scan cache (the read path
// #2828 fixes) and writes it to STATE.md frontmatter. Pre-fix this wrote 1.
const result = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const stateMd = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
// Parse the `progress:` YAML block line-by-line (ReDoS-safe: avoids a nested-quantifier
// regex over the whole block). Find total_phases among the block's indented children.
const lines = stateMd.split(/\r?\n/);
let inProgress = false;
let totalPhases = null;
for (const line of lines) {
if (/^progress:\s*$/.test(line)) { inProgress = true; continue; }
if (inProgress) {
// A new top-level (column-0) key ends the progress block.
if (/^\S/.test(line)) { inProgress = false; continue; }
const tp = line.match(/^\s+total_phases:\s*(\d+)/);
if (tp) { totalPhases = Number(tp[1]); break; }
}
}
assert.ok(
totalPhases !== null,
`progress.total_phases must be written by state sync. STATE.md:\n${stateMd}`,
);
assert.strictEqual(
totalPhases,
6,
`progress.total_phases must be the roadmap count (6) for a flat unmilestoned roadmap, not the on-disk phase-dir count (1). Got: ${totalPhases}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/issue-3204-state-writer-phase-count.test.cjs (H3 Wave 7, #3339)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe('folded:issue-3204-state-writer-phase-count', () => {
// allow-test-rule: source-text-is-the-product, see #3204
// Reads STATE.md/ROADMAP.md fixture files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* #3204 / #3185 — failing-first regression suite for `buildStateFrontmatter`'s
* `total_phases` selection (`src/state.cts:1620`, guard at `:1795-1805`).
*
* `hasMilestoneSectioning` (`src/roadmap-parser.cts:195`) is
* /^#{2,3}\s+(?!Phase\s+\S)/mi
* — true for ANY non-Phase level-2/3 heading, so a FLAT roadmap carrying an
* ordinary structural heading (`## Progress`, `## Overview`, ...) is
* misclassified as milestone-sectioned. `safeToUseRoadmapCount` then goes
* false and the on-disk phase-directory count silently clobbers the
* ROADMAP-declared count — a regression of #2828, reported in #3204 as
* "roadmap declares 6 phases, 4 directories exist, state.record-session
* writes total_phases: 4".
*
* DO NOT fix src/state.cts or src/roadmap-parser.cts from this file. Rows 2,
* 3, and 14 below assert the CORRECT (post-fix) value and currently FAIL —
* that is the point of a failing-first suite. Every other row asserts
* behavior verified to already hold today (see phase-log for the manual CLI
* probes that established each expected value before this file was written).
*
* Rows and naming follow `.gsd/phase/fix-3185-state-writer-phase-count/50-test-matrix.md`
* verbatim (row numbers refer to that matrix, not the 8-row table in
* `40-design.md`).
*
* Driven via `state record-session` (the shape #3204's own report used),
* then read back with `state json --raw` — the product's own frontmatter
* parser — so `progress.total_phases` is asserted as a NUMBER, never a
* regex over rendered STATE.md text. `tests/helpers.cjs`'s `parseFrontmatter`
* only reads flat top-level keys (it does not descend into the nested
* `progress:` block), so `state json --raw` is the correct structured seam
* for a nested field — it is what `tests/state.test.cjs`'s own '#1761
* read-path' and 'milestone-scoped phase counting' suites already use for
* this exact assertion shape.
*/
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');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* Seed `.planning/phases/<padded>-phase-<n>` for each phase number in `nums`,
* each with a single PLAN.md so the directory is a recognizable phase dir.
*/
function seedPhaseDirs(tmpDir, nums) {
for (const n of nums) {
const padded = String(n).padStart(2, '0');
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${n}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan\n');
}
}
/** Seed one arbitrarily-named phase directory (sentinel / dup / pre-milestone cases). */
function seedNamedPhaseDir(tmpDir, dirName, planBase) {
const dir = path.join(tmpDir, '.planning', 'phases', dirName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${planBase}-01-PLAN.md`), '# Plan\n');
}
/**
* Build STATE.md frontmatter + minimal body. `milestone` is always set (the
* #3204 fixture needs it truthy — `getMilestoneInfo` defaults an absent
* `milestone:` field to 'v1.0' anyway, so this pins the same value
* explicitly for every row rather than relying on that fallback).
* Lines are joined with the caller-supplied `eol` (default '\n') — row 14
* reuses this to build the CRLF variant without a second copy.
*/
function buildStateMd({ milestone = 'v1.0', milestoneName = 'Test', totalPhases, currentPhase = '01', eol = '\n' }) {
const lines = [
'---',
'gsd_state_version: 1.0',
`milestone: ${milestone}`,
`milestone_name: ${milestoneName}`,
`current_phase: "${currentPhase}"`,
'status: executing',
'progress:',
` total_phases: ${totalPhases}`,
' completed_phases: 0',
' total_plans: 0',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
`**Current Phase:** ${currentPhase}`,
'**Status:** Executing',
'',
];
return lines.join(eol);
}
/** Invoke `state record-session` (the #3204 entry point) then read back `state json --raw`. */
function recordSessionAndReadTotalPhases(tmpDir) {
const recordResult = runGsdTools(
['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
tmpDir,
);
assert.ok(recordResult.success, `state record-session failed: ${recordResult.error}`);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
return JSON.parse(jsonResult.output);
}
// ─────────────────────────────────────────────────────────────────────────────
// Rows 1-3, 14 — #3204 regression: flat roadmap + a structural heading
// ─────────────────────────────────────────────────────────────────────────────
describe('#3204 buildStateFrontmatter total_phases — flat roadmap misclassified as milestone-sectioned', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('flat roadmap with no structural headings keeps the roadmap count', () => {
// Row 1 (happy path / control) — no non-Phase heading anywhere, so
// hasMilestoneSectioning is false today and this already passes. Guards
// against a fix that overcorrects and breaks the trivial flat case.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected roadmap count 6, got ${out.progress && out.progress.total_phases}`);
});
test('#3204 flat roadmap with a Progress heading is not treated as milestone-sectioned', () => {
// Row 2 — the crux repro, transcribed from #3204's own report: 6
// declared phases, 4 directories, a flat '## Progress' heading. FAILS
// TODAY: hasMilestoneSectioning misclassifies '## Progress' as
// sectioning, safeToUseRoadmapCount goes false, and the write clobbers
// total_phases down to the disk count (4) instead of 6.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Progress',
'',
'Some progress notes.',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204: total_phases must stay 6 (roadmap-declared), not clobber to the disk count of 4. Got ${out.progress && out.progress.total_phases}`,
);
});
test('#3204 multiple structural headings still count as flat', () => {
// Row 3 — same shape as row 2 with TWO structural headings ('## Overview',
// '## Phase Details'); '## Phase Details' is correctly excluded by the
// heading's own '(?!Phase\s+\S)' lookahead, but '## Overview' still trips
// the misclassification. FAILS TODAY for the same reason as row 2.
const roadmap = [
'# Roadmap',
'',
'## Overview',
'',
'Some overview text.',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Phase Details',
'',
'More detail prose.',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204: multiple structural headings must still count as flat (6), got ${out.progress && out.progress.total_phases}`,
);
});
test('#3204 repro under CRLF', () => {
// Row 14 — row 2's exact repro, all fixture content authored with CRLF
// line endings, proving the bug (and required fix) is not an artifact of
// LF-only fixtures. FAILS TODAY for the same reason as row 2.
const roadmapLines = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Progress',
'',
'Some progress notes.',
'',
];
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapLines.join('\r\n'));
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ totalPhases: 6, eol: '\r\n' }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204 under CRLF: total_phases must stay 6, got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rows 4-11 — negative space and boundaries (must hold both before and after the fix)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3204 buildStateFrontmatter total_phases — negative space / boundaries (must not regress)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('bounded milestone uses its own section count', () => {
// Row 4 — versioned roadmap with two sibling milestone sections ('## v1.0'
// owning phases 1-2, '## v2.0' owning phases 3-5). The asserted milestone
// ('v2.0') IS bound to its own heading, so `sliceMilestoneWindow`/
// `extractCurrentMilestoneScoped` narrow to that section and
// roadmapPhaseCount is the SECTION's count (3), not the whole-document
// count (5) and not the disk count (2 dirs seeded).
const roadmap = [
'# Roadmap',
'',
'## v1.0',
'## Phase 1: One',
'## Phase 2: Two',
'',
'## v2.0',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v2.0', milestoneName: 'Second', totalPhases: 3 }),
);
seedPhaseDirs(tmpDir, [3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`a bounded milestone must use its own section's phase count (3), not the whole document (5) or the disk count (2). Got ${out.progress && out.progress.total_phases}`,
);
});
test('a single milestone section that is not the asserted one withholds the total (#3642)', () => {
// Row 6, REWRITTEN by #3642 (maintainer-confirmed bug; the old contract
// here was the bug). Exactly ONE '## v2.0' section owning phases, with
// the asserted milestone ('v9.9') absent from the roadmap entirely. The
// old row pinned that hasMilestoneSectioning's >=2 threshold reads this
// as flat, so the roadmap-declared count (4) was used for v9.9 — which
// IS the leak #3642 reports: the v2.0 section's phases became a
// different milestone's total. The #3354 withhold doctrine governs both
// faces now: neither the whole-document count (it is the foreign
// section's phases) NOR the on-disk dir count is authoritative for a
// milestone absent from the roadmap, so the STORED value (99, chosen to
// differ from every substitute) must be preserved.
const roadmap = [
'# Roadmap',
'',
'## v2.0',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Absent', totalPhases: 99 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
99,
`#3642: a single non-matching section must not leak its phases into the asserted milestone's total; stored 99 expected, got ${out.progress && out.progress.total_phases}`,
);
// The clobber must also be SURFACED, not silent: drive the seam directly
// (runGsdTools discards stderr on success) and require the #3642 warning
// naming the asserted milestone.
const { runNode } = require('./helpers/process-seam.cjs');
const { TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
const rec = runNode(
[TOOLS_PATH, 'state', 'json', '--raw'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state json --raw failed: ${rec.stderr}`);
assert.ok(
(rec.stderr || '').includes('v9.9') && (rec.stderr || '').includes('#3642'),
`#3642: expected a stderr warning naming the asserted milestone and the issue, got stderr=${JSON.stringify(rec.stderr)}`,
);
});
test('#1761/#3354 sibling milestone sections preserve the stored total', () => {
// Row 5 — TWO sibling (unversioned) milestone sections, asserted
// milestone ('v3.0') absent from either. This is genuinely
// milestone-sectioned (2 phase-bearing sections would conflate if
// whole-doc counted), so neither the whole-doc count NOR the on-disk dir
// count is an authoritative total (#3354): the stored value must be
// preserved instead. Pre-#3354 this row asserted the disk count (3).
const roadmap = [
'# 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');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 8 }),
);
seedPhaseDirs(tmpDir, [1, 2, 3]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
8,
`#1761/#3354: unbounded sibling milestones must preserve the stored total (8), not clobber to the disk count (3). Got ${out.progress && out.progress.total_phases}`,
);
});
test('zero phase directories keeps the declared count', () => {
// Row 7 — boundary limit-1: 0 dirs vs 6 declared.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
// No phase dirs seeded.
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
});
test('equal counts agree', () => {
// Row 8 — boundary limit: 6 dirs vs 6 declared.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
});
test('extra directories win via max()', () => {
// Row 9 — boundary limit+1. 6 heading-declared phases + a 7th phase
// declared only via the bullet-entry syntax ('- [ ] **Phase 7 — Extra**',
// #2199 bullet house style), which the directory-membership filter
// counts but the heading-only roadmapPhaseCount scan does not — so disk
// (7, all pass the membership filter) legitimately exceeds the
// heading-only roadmap count (6), and max() must pick 7.
//
// NOTE: a naive "N heading-declared phases + N+1 plain directories" does
// NOT exercise this path — the directory-membership filter
// (getMilestonePhaseFilter, roadmap-parser.cts) excludes any directory
// whose phase number has no matching roadmap entry at all, so an
// out-of-roadmap directory number is silently dropped from the disk
// count rather than inflating it. Verified against the running CLI
// before authoring this fixture.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'- [ ] **Phase 7 — Extra**',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6, 7]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
7,
`expected max(7 dirs, 6 heading-declared) = 7, got ${out.progress && out.progress.total_phases}`,
);
});
test('absent roadmap falls back to disk', () => {
// Row 10 — no ROADMAP.md at all.
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
});
test('roadmap with no phase headings falls back to disk', () => {
// Row 11 — ROADMAP.md present but zero Phase headings anywhere.
const roadmap = ['# Roadmap', '', '## Notes', '', 'No phases declared yet.', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
});
test('a phase heading carrying a version token is not a milestone heading', () => {
// Row 12 (hostile, negative space) — '### Phase 3: Ship v2.0 gaps' carries
// a version token in its own text, but hasMilestoneSectioning's
// isPhaseHeading check excludes any heading matching '^Phase\s+\S' before
// the vocabulary signal is ever tested, so this must NOT count as a
// milestone heading. Otherwise-flat roadmap, so the roadmap-declared
// count must be used, not the disk count.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'### Phase 3: Ship v2.0 gaps',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`a version token borne by a Phase heading must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
);
});
test('a version heading inside a fence is not sectioning', () => {
// Row 13 (hostile, negative space) — '## v2.0' appears only inside a
// fenced code block (a documentation example of the heading syntax) on an
// otherwise flat roadmap. hasMilestoneSectioning is routed through
// tokenizeHeadings (fence-aware), so a fenced heading is never tokenised
// and must NOT count as sectioning. The roadmap-declared count must be
// used, not the disk count.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'',
'Example heading syntax:',
'',
'```',
'## v2.0',
'```',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`a version heading inside a fence must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3185 adversarial review — hasMilestoneSectioning ownership-model shapes
// missed by the original suite (BLOCKER + MAJOR findings against the #3184
// "strictly-deeper nesting" rewrite).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3185 review — hasMilestoneSectioning shapes the original suite missed', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('BLOCKER: same-level sibling milestones preserve the stored total', () => {
// Adversarial review BLOCKER (#1761 regression): the #3184 rewrite
// required a candidate milestone heading's owned Phase heading to be
// STRICTLY DEEPER (next.level > candidate.level). Real sibling
// milestones are frequently at the SAME level as their own Phase
// headings ('## v1.0' / '## Phase 1:' / '## v2.0' / '## Phase 3:'), so
// that predicate answered false and the whole-document count conflated
// both milestones. The asserted milestone ('v3.0') is unbound (matches
// neither v1.0 nor v2.0), so this is genuinely sectioned and neither
// the whole-doc count NOR the disk count may be written (#3354): the
// stored value (4) must be preserved. Pre-#3354 this row asserted the
// disk count (2).
const roadmap = [
'# Roadmap',
'',
'## v1.0',
'## Phase 1: One',
'## Phase 2: Two',
'',
'## v2.0',
'## Phase 3: Three',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 4 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`same-level sibling milestones must preserve the stored total (4), not clobber to the disk count (2). Got ${out.progress && out.progress.total_phases}`,
);
});
test('#3185 repro: structural headings interleaved among flat phases keep the roadmap count', () => {
// #3185's own reproduction of the adjacency model this suite's
// predecessor shipped: '## Overview' sits immediately before
// '## Phase 1:' and '## Notes' sits immediately before '## Phase 4:',
// giving an adjacency-based predicate 2 "owning" candidates even though
// neither heading carries any milestone vocabulary (no version token, no
// status marker, no "Milestone" word) and the roadmap is genuinely flat.
const roadmap = [
'# Roadmap',
'',
'## Overview',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Notes',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Test', totalPhases: 6 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3185: structural headings adjacent to phase headings must not be treated as milestone sectioning; expected roadmap count 6, got ${out.progress && out.progress.total_phases}`,
);
});
test('MAJOR: wrapper + single nested milestone, asserted elsewhere, withholds the total (#3642)', () => {
// Adversarial review MAJOR (#3204 reintroduction), REWRITTEN by #3642
// (maintainer-confirmed bug). The row's original purpose survives: a
// generic wrapper ('## Phases') with ONE real milestone nested under it
// (bundled template shape: '### 🚧 v1.1 [Name]' -> '#### Phase N:') is
// NOT milestone-SECTIONED — hasMilestoneSectioning's >=2 still says
// false, pinned by the #3642 seam rows. But the row's old ASSERTION
// pinned the leak #3642 reports: with the asserted milestone ('v9.9')
// deliberately unbound, the roadmap count (4) — which IS the v1.1
// section's phases — was written as v9.9's total. Pre-#3354 that arm
// existed to stop a clobber to the disk count (2); the #3354/#3642
// withhold doctrine supersedes it: preserve the stored value (8, chosen
// to differ from every substitute) and warn.
const roadmap = [
'# Roadmap',
'',
'## Phases',
'',
'### 🚧 v1.1 [Name] (In Progress)',
'',
'#### Phase 1: One',
'#### Phase 2: Two',
'#### Phase 3: Three',
'#### Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Unbound', totalPhases: 8 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
8,
`#3642: a single real section's phases must not become an absent milestone's total; stored 8 expected. Got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rows 15-18 — #3185 consolidation independence checks
//
// These exercise the directory-enumeration owner (listMilestonePhaseDirs /
// getMilestonePhaseFilter), not hasMilestoneSectioning. Verified PASSING
// against the current build (manual CLI probe) before being added here —
// included per the dispatch brief's "include only if they pass today"
// condition. If a future change to the #3204 fix regresses one of these,
// that is a SEPARATE finding from the #3204 repro above, not folded into it.
// ─────────────────────────────────────────────────────────────────────────────
describe('#3185 buildStateFrontmatter total_phases — directory-enumeration independence (currently passing)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('sentinel directories are excluded by the canonical enumeration', () => {
// Row 15 — a 999.x backlog directory alongside 3 real phase directories
// must not inflate total_phases.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '999.1-backlog-idea', '999.1');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`sentinel 999.x directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
);
});
test('pre-milestone directories are excluded', () => {
// Row 16 — a '0-*' pre-milestone directory must not be counted.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '0-premilestone', '0');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`pre-milestone '0-*' directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
);
});
test('duplicate phase-number directories count once', () => {
// Row 17 — two directories both keyed to phase number 2 must dedup to a
// single count.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '02-phase-2-dup', '02');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`duplicate phase-2 directories must dedup to a single count (3), got ${out.progress && out.progress.total_phases}`,
);
});
test('re-running record-session does not move total_phases', () => {
// Row 18 — idempotence: a second record-session call over an unchanged
// tree, with the clock pinned so 'Last session' does not itself vary,
// must produce a byte-identical STATE.md.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const sessionState = [
'# GSD State',
'',
'## Session',
'',
'**Last session:** 2024-01-01T00:00:00.000Z',
'**Stopped at:** None',
'**Resume file:** None',
'',
'## Current Position',
'',
'**Current Phase:** 01',
'**Status:** Executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionState);
seedPhaseDirs(tmpDir, [1, 2, 3]);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const pinnedEnv = { GSD_TEST_MODE: '1', GSD_NOW_MS: '1600000000000' };
const args = ['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'];
const first = runGsdTools(args, tmpDir, pinnedEnv);
assert.ok(first.success, `first record-session failed: ${first.error}`);
const afterFirst = fs.readFileSync(statePath, 'utf8');
const second = runGsdTools(args, tmpDir, pinnedEnv);
assert.ok(second.success, `second record-session failed: ${second.error}`);
const afterSecond = fs.readFileSync(statePath, 'utf8');
assert.strictEqual(
afterSecond,
afterFirst,
're-running record-session on an unchanged tree with a pinned clock must produce a byte-identical STATE.md',
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3355 — same-milestone phase-dir collision: the seenPhaseNums dedup loop in
// buildStateFrontmatter resolved duplicate phase keys by fs mtime, which
// encodes checkout write order, not repository content — identical commits
// reported different progress.total_plans / completed_plans across clones.
// The tie-break must be content-derived and the collision surfaced, while the
// Bug #2445 one-survivor-per-key invariant is preserved.
// ─────────────────────────────────────────────────────────────────────────────
describe('#3355 phase-dir dedup — collision tie-break must not consult mtime', () => {
const { runNode } = require('./helpers/process-seam.cjs');
const { TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
// Both normalize to phase key '3' (phaseKeyFromDir) and are IN scope on a
// flat roadmap, so they reach the dedup loop unfiltered. Distinct plan /
// summary counts make the survivor observable in the derived progress
// counts. Lexicographically 'mi' < 'mv', so DIR_LEX is the deterministic
// survivor; DIR_MTIME is the pre-fix winner whenever it is mtime-newer.
const DIR_LEX = '03-mi-cuenta-y-contactos';
const DIR_MTIME = '03-mvp-modulos-portal-cliente';
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-3355-');
});
afterEach(() => {
cleanup(tmpDir);
});
function buildCollisionFixture() {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n'),
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2]); // phases 01 + 02, one plan each
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const lexDir = path.join(phasesDir, DIR_LEX);
fs.mkdirSync(lexDir, { recursive: true });
fs.writeFileSync(path.join(lexDir, '03-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(lexDir, '03-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(lexDir, '03-01-SUMMARY.md'), '# Summary\n');
const mtimeDir = path.join(phasesDir, DIR_MTIME);
fs.mkdirSync(mtimeDir, { recursive: true });
for (let i = 1; i <= 5; i++) {
fs.writeFileSync(path.join(mtimeDir, `03-0${i}-PLAN.md`), '# Plan\n');
}
for (let i = 1; i <= 3; i++) {
fs.writeFileSync(path.join(mtimeDir, `03-0${i}-SUMMARY.md`), '# Summary\n');
}
return { phasesDir, lexDir, mtimeDir };
}
function readProgress() {
const result = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(result.success, `state json --raw failed: ${result.error}`);
return JSON.parse(result.output).progress;
}
test('#3355 flipping the colliding dirs\' mtimes keeps counts byte-identical, warns naming both dirs, one survivor per key', () => {
const { lexDir, mtimeDir } = buildCollisionFixture();
// The issue reported a 0.133 ms mtime margin flipping the winner, but
// filesystem timestamp granularity is platform-dependent (APFS rounds
// utimes to whole ms), so the flip uses a margin guaranteed to register
// everywhere — pre-fix, the mtime-newer duplicate then won the dedup
// regardless of repository content. `_diskScanCache` is process-local
// and runGsdTools spawns a fresh node per call, so each read below is a
// cold scan exactly like a fresh checkout.
const base = new Date('2026-01-01T00:00:00.000Z');
const margin = new Date(base.getTime() + 1500);
fs.utimesSync(lexDir, base, base);
fs.utimesSync(mtimeDir, margin, margin); // DIR_MTIME mtime-newer — must NOT win
const first = readProgress();
// Deterministic lexicographic survivor: 01 (1 plan) + 02 (1 plan) +
// DIR_LEX (2 plans) = 4 — never the mtime-newer DIR_MTIME's 7, never the
// un-deduped sum of both (Bug #2445 invariant, 9).
assert.strictEqual(
Number(first.total_plans),
4,
`#3355: total_plans must follow the lexicographic survivor (${DIR_LEX}: 1+1+2=4), got ${first && first.total_plans} — mtime still deciding the collision?`,
);
assert.strictEqual(
Number(first.completed_plans),
1,
`#3355: completed_plans must count only the survivor's summaries (1), got ${first && first.completed_plans}`,
);
// The collision must be surfaced: stderr warning naming BOTH dirs
// (runGsdTools discards stderr on success, so drive the seam directly).
const rec = runNode(
[TOOLS_PATH, 'state', 'json', '--raw'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state json --raw failed: ${rec.stderr}`);
assert.ok(
(rec.stderr || '').includes(DIR_LEX) && (rec.stderr || '').includes(DIR_MTIME),
`#3355: expected a stderr warning naming both colliding dirs, got stderr=${JSON.stringify(rec.stderr)}`,
);
// Flip the mtimes — byte content unchanged, only checkout order would
// differ. Every derived count must stay byte-identical.
fs.utimesSync(mtimeDir, base, base);
fs.utimesSync(lexDir, margin, margin);
const second = readProgress();
assert.strictEqual(second.total_plans, first.total_plans, `#3355: total_plans moved after the mtime flip (${first.total_plans} → ${second.total_plans})`);
assert.strictEqual(second.completed_plans, first.completed_plans, `#3355: completed_plans moved after the mtime flip (${first.completed_plans} → ${second.completed_plans})`);
assert.strictEqual(second.percent, first.percent, `#3355: percent moved after the mtime flip (${first.percent} → ${second.percent})`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3354 — milestoned-but-unbounded: a genuinely milestone-sectioned ROADMAP
// whose asserted milestone token matches no H1–H3 heading must not have its
// progress.total_phases clobbered to the on-disk phase-directory count.
// Surviving branch of #2828/#3204: #3204 closed only the flat-unmilestoned
// case; the sectioned-but-unbounded arm of `safeToUseRoadmapCount` still
// wrote phaseDirs.length (25 → 4 in the issue's report).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3354 buildStateFrontmatter total_phases — milestoned-but-unbounded roadmap', () => {
const { runNode } = require('./helpers/process-seam.cjs');
const { TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
/** The #3354 crux fixture: 2 milestone-vocabulary sections, 25 phase headings across siblings. */
function buildSectionedRoadmap() {
const lines = ['# Roadmap', ''];
lines.push('## Milestone v2.0 — Alpha', '');
for (let i = 1; i <= 12; i++) lines.push(`### Phase ${i}: alpha-${i}`);
lines.push('');
lines.push('## Milestone v3.0 — Beta', '');
for (let i = 13; i <= 25; i++) lines.push(`### Phase ${i}: beta-${i}`);
lines.push('');
return lines.join('\n');
}
test('#3354 stored total is preserved (not clobbered to the dir count), a stderr warning names the token, percent stays withheld', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
// milestone: v1.0 appears in NO heading of that roadmap — unbounded.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', milestoneName: 'Unbounded', totalPhases: 25 }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
// Drive the mutating command with stderr captured (runGsdTools discards
// stderr on success). The warning is asserted on THIS invocation.
const rec = runNode(
[TOOLS_PATH, 'state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
assert.ok(
/v1\.0/.test(rec.stderr || ''),
`#3354: expected a stderr warning naming the unbounded milestone token 'v1.0', got stderr=${JSON.stringify(rec.stderr)}`,
);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
const out = JSON.parse(jsonResult.output);
assert.strictEqual(
Number(out.progress.total_phases),
25,
`#3354: total_phases must preserve the stored 25, not clobber to the on-disk dir count of 4. Got ${out.progress && out.progress.total_phases}`,
);
assert.ok(
!(out.progress && ('percent' in out.progress)),
`#3354: percent must stay withheld for an unbounded milestone (existing #1761 guard); got progress=${JSON.stringify(out.progress)}`,
);
});
test('#3354 with nothing stored, the key is omitted rather than written from the dir count', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
// No progress block in frontmatter, no "Total Phases" body annotation —
// nothing stored to preserve, so the key must be OMITTED (never 4).
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Unbounded',
'current_phase: "01"',
'status: executing',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
'**Current Phase:** 01',
'**Status:** Executing',
'',
].join('\n'),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const recordResult = runGsdTools(
['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
tmpDir,
);
assert.ok(recordResult.success, `state record-session failed: ${recordResult.error}`);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
const out = JSON.parse(jsonResult.output);
assert.ok(
!(out.progress && ('total_phases' in out.progress)),
`#3354: with no stored total, the key must be omitted — never written from the dir count. Got progress=${JSON.stringify(out.progress)}`,
);
});
});
// #3573 — roadmap-absent sibling of the #3354 shape: when ROADMAP.md is
// absent/unreadable while STATE.md asserts a milestone, the #549 heading counter
// never runs (roadmapScope === null) and every state.* write persisted the on-disk
// phase-directory count as progress.total_phases — counting only phases that have
// STARTED, quietly defeating #549's single-source-of-truth (5 → 1 in the issue's
// report). The stored frontmatter total must win, with a stderr warning.
describe('#3573 total_phases — roadmap absent with an asserted milestone', () => {
const { runNode } = require('./helpers/process-seam.cjs');
const { TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
/** Read the PERSISTED progress.total_phases straight out of STATE.md (the corruption is a file write, not a read derivation). */
function persistedTotalPhases(dir) {
const raw = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
const m = raw.match(/^\s{2}total_phases:\s*(\d+)\s*$/m);
return m ? Number(m[1]) : null;
}
function recordSession(dir, stoppedAt = 'Phase 1, Plan 1') {
return runNode(
[TOOLS_PATH, 'state', 'record-session', '--stopped-at', stoppedAt, '--resume-file', 'none'],
{ cwd: dir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
}
test('#3573: roadmap-absent write keeps the stored total_phases instead of the phase-directory count', () => {
// No ROADMAP.md at all — the issue's "scoping fails" endpoint. STATE asserts
// milestone v1.0 with a stored total of 5; one phase directory exists.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', totalPhases: 5 }),
);
seedPhaseDirs(tmpDir, [1]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
assert.match(
rec.stderr || '',
/\(#3573\)/,
`a stderr warning must name the roadmap-absent condition; got stderr=${JSON.stringify(rec.stderr)}`,
);
assert.strictEqual(
persistedTotalPhases(tmpDir),
5,
`total_phases must stay at the stored 5, not the phase-directory count of 1`,
);
});
test('#3573: begin-phase write keeps the stored total_phases (second issue-named verb)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', totalPhases: 5, currentPhase: '02' }),
);
seedPhaseDirs(tmpDir, [1]);
// ADR-3473 §8.4 / #3358: a bare positional phase ("state begin-phase 2")
// is now a rejected, undeclared token — see
// tests/state.test.cjs positionalPlannedPhaseLeavesStateMdUntouched_3358,
// the sibling regression for "planned-phase" that locks in exactly this
// rejection. Use the documented "--phase N" flag form (see
// CLI-TOOLS.md line 116 in the docs directory) instead; this test's own
// assertions were never about the bare-positional shape itself, only
// about total_phases surviving the resync.
const rec = runNode(
[TOOLS_PATH, 'state', 'begin-phase', '--phase', '2'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state begin-phase failed: ${rec.stderr}`);
assert.strictEqual(
persistedTotalPhases(tmpDir),
5,
`total_phases must stay at the stored 5 across begin-phase's frontmatter resync`,
);
});
test('#3573: no milestone asserted + roadmap absent keeps the directory count (fresh-project doctrine)', () => {
// The #3354 doctrine: with nothing declared anywhere else, the disk count is
// the only source and stays authoritative. A milestone-less STATE must keep
// deriving total_phases from the directories — stored 5, dirs 2, expect 2,
// so the row DISCRIMINATES: a withhold that fires without the milestone
// gate would leave 5 and fail here (mutation-kill guard).
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'none', totalPhases: 5 }).replace(/^milestone: none$/m, ''),
);
seedPhaseDirs(tmpDir, [1, 2]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
assert.strictEqual(
persistedTotalPhases(tmpDir),
2,
`without an asserted milestone, the directory count (2) remains the source — not the stored 5`,
);
});
test('#3573: state json read surface agrees with the persisted file (write/read parity)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', totalPhases: 5 }),
);
seedPhaseDirs(tmpDir, [1]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
const out = JSON.parse(jsonResult.output);
assert.strictEqual(
Number(out.progress && out.progress.total_phases),
5,
`#3573 read parity: state json must report the preserved stored 5, not the dir count of 1. Got ${out.progress && out.progress.total_phases}`,
);
});
test('#3573: planned-phase write keeps the stored total_phases (third issue-named verb)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', totalPhases: 5, currentPhase: '02' }),
);
seedPhaseDirs(tmpDir, [1]);
// ADR-3473 §8.4 / #3358: a bare positional phase ("state planned-phase 2")
// is now a rejected, undeclared token — see
// tests/state.test.cjs positionalPlannedPhaseLeavesStateMdUntouched_3358,
// the regression test that locks in exactly this rejection for this same
// subcommand. Use the documented "--phase N" flag form (see
// COMMANDS.md line 2192 in the docs directory) instead; this test's own
// assertions were never about the bare-positional shape itself, only
// about total_phases surviving the resync.
const rec = runNode(
[TOOLS_PATH, 'state', 'planned-phase', '--phase', '2', '--name', 'Core'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state planned-phase failed: ${rec.stderr}`);
assert.strictEqual(
persistedTotalPhases(tmpDir),
5,
`total_phases must stay at the stored 5 across planned-phase's frontmatter resync`,
);
});
test('#3573 (control): roadmap-present scoped write derives from headings, unchanged', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Milestone v1.0', '', ...[1, 2, 3, 4, 5].map((i) => `### Phase ${i}: p${i}`), ''].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v1.0', totalPhases: 5 }),
);
seedPhaseDirs(tmpDir, [1]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
assert.strictEqual(
persistedTotalPhases(tmpDir),
5,
`scoped roadmap keeps the heading-derived total of 5`,
);
});
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// #4094 — the #3354 milestone-unbounded withhold only protects
// progress.total_phases. completed_phases / total_plans / completed_plans are
// accumulated from the SAME phaseDirs walk (same IIFE, same loop) but were
// returned unconditionally and assigned straight from cached.* on every
// resyncing write, silently clobbering stored values under the exact
// condition #3354 established the scan is untrustworthy for. Extend the
// withhold-then-fall-back-to-stored pattern to all three siblings.
// Matrix: .gsd/bug/fix-4094-milestone-withhold-all-counters/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('#4094 milestone-unbounded withhold — all four progress counters', () => {
// Local requires: this block sits after the closing brace of the section
// that owned the module-level beforeEach/afterEach destructure above, so
// (mirroring the #3642 block below) everything it needs is required here.
const { test, 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 { runNode } = require('./helpers/process-seam.cjs');
const { TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
/** Seed `.planning/phases/<padded>-phase-<n>` — local copy (the block-scoped seeder at the top of this file is not reachable from here). */
function seedPhaseDirs(dir, nums) {
for (const n of nums) {
const padded = String(n).padStart(2, '0');
const phaseDir = path.join(dir, '.planning', 'phases', `${padded}-phase-${n}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan\n');
}
}
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
/** The #3354/#4094 crux fixture: two milestone sections, asserted milestone matches neither. */
function buildSectionedRoadmap() {
const lines = ['# Roadmap', ''];
lines.push('## Milestone v2.0 — Alpha', '');
for (let i = 1; i <= 12; i++) lines.push(`### Phase ${i}: alpha-${i}`);
lines.push('');
lines.push('## Milestone v3.0 — Beta', '');
for (let i = 13; i <= 25; i++) lines.push(`### Phase ${i}: beta-${i}`);
lines.push('');
return lines.join('\n');
}
/**
* STATE.md fixture with an explicit stored progress block. `counters`
* entries that are null/undefined are OMITTED from the frontmatter block
* (the partial-stored rows rely on that).
*/
function buildStateMdWithCounters({ milestone = 'v1.0', counters = {} }) {
const { totalPhases, completedPhases, totalPlans, completedPlans } = counters;
const fm = [
'---',
'gsd_state_version: 1.0',
`milestone: ${milestone}`,
'milestone_name: Unbounded',
'current_phase: "01"',
'status: executing',
];
const rows = [
['total_phases', totalPhases],
['completed_phases', completedPhases],
['total_plans', totalPlans],
['completed_plans', completedPlans],
].filter(([, v]) => v !== null && v !== undefined);
if (rows.length > 0) {
fm.push('progress:');
for (const [k, v] of rows) fm.push(` ${k}: ${v}`);
}
fm.push('---', '', '# GSD State', '', '## Current Position', '', '**Current Phase:** 01', '**Status:** Executing', '');
return fm.join('\n');
}
/** Read the PERSISTED progress block values straight out of STATE.md. */
function persistedProgress(dir) {
const raw = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
const lines = splitLines(raw);
const out = {};
let inProgress = false;
for (const line of lines) {
if (/^progress:\s*$/.test(line)) { inProgress = true; continue; }
if (!inProgress) continue;
const kv = line.match(/^ {2}(\w+): (.+)$/);
if (!kv) break; // progress block ended
out[kv[1]] = kv[2].trim();
}
return out;
}
function recordSession(dir, stoppedAt = 'Phase 1, Plan 1') {
return runNode(
[TOOLS_PATH, 'state', 'record-session', '--stopped-at', stoppedAt, '--resume-file', 'none'],
{ cwd: dir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
}
function stateJson(dir) {
const jsonResult = runGsdTools(['state', 'json', '--raw'], dir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
return JSON.parse(jsonResult.output);
}
// Row 1 — the failing-first regression: all four counters preserved.
test('#4094 stored completed_phases/total_plans/completed_plans are preserved alongside total_phases (milestoned-but-unbounded)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 25, completedPhases: 9, totalPlans: 60, completedPlans: 40 } }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_phases, '25', `#4094: total_phases must keep the stored 25 (existing #3354 guard)`);
assert.strictEqual(p.completed_phases, '9', `#4094: completed_phases must keep the stored 9, not the disk-scan 0. Got ${p.completed_phases}`);
assert.strictEqual(p.total_plans, '60', `#4094: total_plans must keep the stored 60, not the disk-scan 4. Got ${p.total_plans}`);
assert.strictEqual(p.completed_plans, '40', `#4094: completed_plans must keep the stored 40, not the disk-scan 0. Got ${p.completed_plans}`);
});
// Row 2 — the #3573 roadmap-absent sibling of the same withhold.
test('#4094 roadmap-absent withhold preserves the three sibling counters too', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 5, completedPhases: 2, totalPlans: 11, completedPlans: 7 } }),
);
seedPhaseDirs(tmpDir, [1]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_phases, '5', `#4094: total_phases must keep the stored 5`);
assert.strictEqual(p.completed_phases, '2', `#4094: completed_phases must keep the stored 2, not the disk-scan 0. Got ${p.completed_phases}`);
assert.strictEqual(p.total_plans, '11', `#4094: total_plans must keep the stored 11, not the disk-scan 1. Got ${p.total_plans}`);
assert.strictEqual(p.completed_plans, '7', `#4094: completed_plans must keep the stored 7, not the disk-scan 0. Got ${p.completed_plans}`);
});
// Row 3 — nothing stored: all four keys omitted, never written from the disk scan.
test('#4094 with nothing stored, all four counter keys are omitted', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMdWithCounters({}));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans']) {
assert.ok(!(key in p), `#4094: with nothing stored, '${key}' must be omitted — never written from the disk scan. Got ${JSON.stringify(p)}`);
}
});
// Row 4 — partial stored block: stored ones preserved, unstated ones omitted.
test('#4094 partial stored block: stored counters preserved, absent counters omitted', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 25, totalPlans: 60 } }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_phases, '25', `#4094: stored total_phases preserved`);
assert.strictEqual(p.total_plans, '60', `#4094: stored total_plans preserved. Got ${p.total_plans}`);
assert.ok(!('completed_phases' in p), `#4094: unstored completed_phases must be omitted. Got ${JSON.stringify(p)}`);
assert.ok(!('completed_plans' in p), `#4094: unstored completed_plans must be omitted. Got ${JSON.stringify(p)}`);
});
// Rows 5-8 — boundary hold-1/hold/hold+1 per counter: the gate is boolean,
// so preservation must not depend on how the stored value relates to the
// disk-scan value. Rows 5-7 are the three newly-protected counters; row 8
// (total_phases) is the already-green control.
const BOUNDARY_SPECS = [
{ key: 'completed_phases', fixtureKey: 'completedPhases', disk: 0, name: 'completed_phases' },
{ key: 'total_plans', fixtureKey: 'totalPlans', disk: 4, name: 'total_plans' },
{ key: 'completed_plans', fixtureKey: 'completedPlans', disk: 0, name: 'completed_plans' },
{ key: 'total_phases', fixtureKey: 'totalPhases', disk: 4, name: 'total_phases (control)' },
];
for (const spec of BOUNDARY_SPECS) {
for (const delta of [-1, 0, 1]) {
test(`#4094 boundary: ${spec.name} preserved at disk${delta >= 0 ? '+' : ''}${delta}`, () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
const stored = spec.disk + delta;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { [spec.fixtureKey]: stored } }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(
p[spec.key],
String(stored),
`#4094 boundary: ${spec.key} must keep the stored ${stored} regardless of the disk count ${spec.disk}. Got ${JSON.stringify(p)}`,
);
});
}
}
// Row 9 — legit-decrease negative space: on a BOUNDED milestone the disk
// scan stays authoritative and corrections still land downward.
test('#4094 bounded milestone: disk-scan corrections still land (plan deletion)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Milestone v1.0', '', ...[1, 2].map((i) => `### Phase ${i}: p${i}`), ''].join('\n'),
);
seedPhaseDirs(tmpDir, [1, 2]);
// One plan in phase 1, two plans in phase 2.
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '02-phase-2', '02-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 2, completedPhases: 0, totalPlans: 3, completedPlans: 1 } }),
);
// Delete one of phase 2's plans — a legitimate decrease the scan must apply.
fs.unlinkSync(path.join(tmpDir, '.planning', 'phases', '02-phase-2', '02-02-PLAN.md'));
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_plans, '2', `#4094 negative space: bounded scan must correct total_plans 3 -> 2 after plan deletion. Got ${p.total_plans}`);
});
// Row 10 — legit-decrease negative space: superseded plans still excluded on a bounded milestone.
test('#4094 bounded milestone: superseded plans still excluded from counts', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Milestone v1.0', '', ...[1, 2].map((i) => `### Phase ${i}: p${i}`), ''].join('\n'),
);
seedPhaseDirs(tmpDir, [1, 2]);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'phases', '02-phase-2', '02-02-PLAN.md'),
'---\nstatus: superseded\n---\n# Superseded plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 2, completedPhases: 0, totalPlans: 3, completedPlans: 0 } }),
);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_plans, '2', `#4094 negative space: superseded plan must drop from total_plans on a bounded milestone. Got ${p.total_plans}`);
});
// Row 11 — legit-decrease negative space: phase-directory removal still corrects counters when bounded.
test('#4094 bounded milestone: phase-directory removal still corrects counters', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
['# Roadmap', '', '## Milestone v1.0', '', ...[1, 2].map((i) => `### Phase ${i}: p${i}`), ''].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 2, completedPhases: 0, totalPlans: 2, completedPlans: 0 } }),
);
seedPhaseDirs(tmpDir, [1, 2]);
// Removing a FIXTURE subdirectory (a phase dir), not the temp dir itself.
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- deliberate fixture mutation inside tmpDir, not a temp-dir teardown
fs.rmSync(path.join(tmpDir, '.planning', 'phases', '02-phase-2'), { recursive: true });
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.total_phases, '2', `#4094 negative space: bounded roadmap keeps heading-derived total 2. Got ${p.total_phases}`);
assert.strictEqual(p.total_plans, '1', `#4094 negative space: bounded scan must correct total_plans 2 -> 1 after phase-dir removal. Got ${p.total_plans}`);
});
// Row 12 — read-surface parity: `state json` reports the preserved values.
test('#4094 state json reports preserved counters under the withhold', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildSectionedRoadmap());
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 25, completedPhases: 9, totalPlans: 60, completedPlans: 40 } }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const rec = recordSession(tmpDir);
assert.ok(rec.exitCode === 0, `state record-session failed: ${rec.stderr}`);
const out = stateJson(tmpDir);
assert.strictEqual(Number(out.progress && out.progress.total_phases), 25, `#4094 json parity: total_phases 25`);
assert.strictEqual(Number(out.progress && out.progress.completed_phases), 9, `#4094 json parity: completed_phases must report the preserved 9. Got ${JSON.stringify(out.progress)}`);
assert.strictEqual(Number(out.progress && out.progress.total_plans), 60, `#4094 json parity: total_plans must report the preserved 60. Got ${JSON.stringify(out.progress)}`);
assert.strictEqual(Number(out.progress && out.progress.completed_plans), 40, `#4094 json parity: completed_plans must report the preserved 40. Got ${JSON.stringify(out.progress)}`);
});
// Row 13 — the `state sync` special write path keeps the stored counters too
// (same withhold, same gate — #3573 already threads the stored total there).
test('#4094 state sync keeps stored counters under the withhold', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMdWithCounters({ counters: { totalPhases: 5, completedPhases: 2, totalPlans: 11, completedPlans: 7 } }),
);
seedPhaseDirs(tmpDir, [1]);
const rec = runNode(
[TOOLS_PATH, 'state', 'sync'],
{ cwd: tmpDir, env: { ...process.env, ...TEST_ENV_BASE }, timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS },
);
assert.ok(rec.exitCode === 0, `state sync failed: ${rec.stderr}`);
const p = persistedProgress(tmpDir);
assert.strictEqual(p.completed_phases, '2', `#4094: state sync must keep stored completed_phases 2. Got ${p.completed_phases}`);
assert.strictEqual(p.total_plans, '11', `#4094: state sync must keep stored total_plans 11. Got ${p.total_plans}`);
assert.strictEqual(p.completed_plans, '7', `#4094: state sync must keep stored completed_plans 7. Got ${p.completed_plans}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3642: hasMilestoneSectioning's >=2 threshold let a single non-matching
// milestone section's phases leak into an unrelated asserted milestone's
// total_phases. Fix: the >=1 sibling (hasAnyMilestoneSection) governs the
// unbounded branch's flat test, so the single-section shape takes the #3354
// withhold (stored value preserved + warning) instead of substituting the
// whole-document count. Controls pin what must NOT change.
// Matrix: .gsd/bug/fix-3642-milestone-sectioning-leak/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('#3642 — single-section leak controls and seam pins', () => {
const { test, 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');
// Compact local builders (this file's sections are deliberately
// self-contained; the #3204 suite's identical builders live in its own
// scope).
function seedDirs(tmpDir, nums) {
for (const n of nums) {
const padded = String(n).padStart(2, '0');
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${n}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan\n');
}
}
function stateMd({ milestone, totalPhases }) {
return [
'---',
'gsd_state_version: 1.0',
`milestone: ${milestone}`,
'milestone_name: M',
'current_phase: "01"',
'status: executing',
'progress:',
` total_phases: ${totalPhases}`,
' completed_phases: 0',
' total_plans: 0',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
'**Current Phase:** 01',
'**Status:** Executing',
'',
].join('\n');
}
function writeTotalAfterRecord(tmpDir) {
const recordResult = runGsdTools(
['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
tmpDir,
);
assert.ok(recordResult.success, `state record-session failed: ${recordResult.error}`);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
return Number(JSON.parse(jsonResult.output).progress.total_phases);
}
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-3642-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('control: a single section that MATCHES the assert keeps its own count', () => {
// Bounded arm unchanged: asserted v2.0 IS bound to the one heading, so
// the section's own count (4) is used — neither the stored (7) nor the
// disk count (2).
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap', '', '## v2.0', '## Phase 1: One', '## Phase 2: Two',
'## Phase 3: Three', '## Phase 4: Four', '',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd({ milestone: 'v2.0', totalPhases: 7 }));
seedDirs(tmpDir, [1, 2]);
assert.strictEqual(writeTotalAfterRecord(tmpDir), 4,
'bounded single section keeps its own count (4)');
});
test('control: a FLAT roadmap with an unbounded assert keeps the roadmap count (#2828 doctrine)', () => {
// Zero vocabulary headings → genuinely flat → the whole-document count
// is correct (no milestone section exists to conflate with).
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap', '', '## Phase 1: One', '## Phase 2: Two',
'## Phase 3: Three', '## Phase 4: Four', '',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd({ milestone: 'v9.9', totalPhases: 7 }));
seedDirs(tmpDir, [1, 2]);
assert.strictEqual(writeTotalAfterRecord(tmpDir), 4,
'flat roadmap + unbounded assert keeps the roadmap count (4)');
});
test('seam pins: hasAnyMilestoneSection counts >=1; hasMilestoneSectioning stays >=2', () => {
const roadmapParser = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap-parser.cjs'));
assert.strictEqual(typeof roadmapParser.hasAnyMilestoneSection, 'function',
'hasAnyMilestoneSection must be exported for buildStateFrontmatter (#3642)');
const one = ['# Roadmap', '', '## v2.0', '## Phase 1: One'].join('\n');
const two = ['# Roadmap', '', '## v1.0', '## Phase 1: One', '', '## v2.0', '## Phase 2: Two'].join('\n');
const flat = ['# Roadmap', '', '## Phase 1: One'].join('\n');
assert.strictEqual(roadmapParser.hasAnyMilestoneSection(one), true, 'one signal heading is a section');
assert.strictEqual(roadmapParser.hasAnyMilestoneSection(two), true, 'two signal headings is a section');
assert.strictEqual(roadmapParser.hasAnyMilestoneSection(flat), false, 'zero signal headings is flat');
assert.strictEqual(roadmapParser.hasMilestoneSectioning(one), false, '>=2 predicate unchanged: one heading is NOT sectioning');
assert.strictEqual(roadmapParser.hasMilestoneSectioning(two), true, '>=2 predicate unchanged: two headings is sectioning');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #4186 — normalizeStateStatus must derive the `status` token by ANCHORED
// matching against the declared status vocabulary (whole-field value,
// case-insensitive, whitespace-collapsed), never by substring-scanning the
// free-prose body Status field. Prose that merely MENTIONS a status word (a
// `.planning/` path, Italian `verifica*`, `completezza`, `fasi complete`)
// must pass through verbatim — the visible paragraph beats a valid, credible,
// wrong token. The lenient fallback itself is a recorded contract
// (state-md-schema.cts #3873 phase-3 row 26) and is preserved.
// ─────────────────────────────────────────────────────────────────────────────
describe('#4186: normalizeStateStatus anchored status vocabulary — substring traps', () => {
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
// Row 1 — the failing-first regression: a `.planning/` path inside Italian
// prose must NOT land on `planning` (the trigger word lives in the
// DIRECTORY NAME; the reporter measured this exact counter-pair).
test('row 1: a .planning/ path inside prose never yields `planning`', () => {
assert.strictEqual(
normalizeStateStatus('Lavoro sospeso, vedi .planning/STATE.md', null),
'Lavoro sospeso, vedi .planning/STATE.md',
);
});
test('rows 2-5: Italian prose mentioning paths/verification/completeness passes through verbatim', () => {
const prose = [
'Esecuzione in corso su .planning/',
'Fase 34 COMPLETA, VERIFICATA 8/8 e FUSA IN PRODUZIONE',
'Aggiornato .planning/STATE.md dopo la verifica di fase',
'Referto in .planning/phases/34, completezza ok',
];
for (const p of prose) {
assert.strictEqual(normalizeStateStatus(p, null), p, `prose must pass through verbatim: ${p}`);
}
});
test('row 6: Italian verifica-family words do not match the `verif` trigger', () => {
for (const p of ['verifica', 'verificata', 'verifiche', 'ri-verifica']) {
assert.strictEqual(normalizeStateStatus(p, null), p);
}
});
test('rows 7-8: `completezza` and `fasi complete` do not yield `completed`', () => {
assert.strictEqual(normalizeStateStatus('riportata per completezza', null), 'riportata per completezza');
assert.strictEqual(normalizeStateStatus('fasi complete', null), 'fasi complete');
});
test('rows 9-10: control — prose with no trigger words was already verbatim and stays so', () => {
const control = [
'completata / completato / completo / completa / incompleta / completamente',
'Lavoro sospeso in attesa del CEO',
];
for (const p of control) {
assert.strictEqual(normalizeStateStatus(p, null), p);
}
});
test('row 11: English status words embedded in prose sentences are not rewrites either', () => {
assert.strictEqual(normalizeStateStatus('Waiting on the planning department', null), 'Waiting on the planning department');
assert.strictEqual(normalizeStateStatus('Notes done, see log', null), 'Notes done, see log');
assert.strictEqual(
normalizeStateStatus('Discussed the verif steps with QA, paused decision', null),
'Discussed the verif steps with QA, paused decision',
);
});
test('row 12 (existing #3873 row-26 contract): unrecognized text passes through unchanged', () => {
assert.strictEqual(
normalizeStateStatus('totally-unrecognized-status-text', null),
'totally-unrecognized-status-text',
);
});
});
describe('#4186: normalizeStateStatus anchored status vocabulary — documented vocabulary still normalizes', () => {
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
test('rows 13-15: variable handler-written phase statuses (case/whitespace variants)', () => {
assert.strictEqual(normalizeStateStatus('Executing Phase 5', null), 'executing');
assert.strictEqual(normalizeStateStatus('EXECUTING PHASE 5', null), 'executing');
assert.strictEqual(normalizeStateStatus('executing phase 46', null), 'executing');
assert.strictEqual(normalizeStateStatus('Planning Phase 3', null), 'planning');
assert.strictEqual(normalizeStateStatus('Verifying Phase 2', null), 'verifying');
});
test('row 16: `Phase N complete` still lands on `completed` (composes with the #3578 demote guard)', () => {
assert.strictEqual(normalizeStateStatus('Phase 12 complete', null), 'completed');
assert.strictEqual(normalizeStateStatus('Phase 3A complete', null), 'completed');
});
test('rows 17-18: ADR-2207 lifecycle terminal statuses (CONTEXT.md:94 recorded contract)', () => {
assert.strictEqual(normalizeStateStatus('All phases complete', null), 'completed');
assert.strictEqual(normalizeStateStatus('ALL PHASES COMPLETE', null), 'completed');
assert.strictEqual(normalizeStateStatus('v1.0 milestone complete', null), 'completed');
assert.strictEqual(normalizeStateStatus('1.0 milestone complete', null), 'completed');
});
test('rows 19-25: fixed-form handler defaults, including case and whitespace variants', () => {
assert.strictEqual(normalizeStateStatus('Ready to plan', null), 'planning');
assert.strictEqual(normalizeStateStatus('Ready to execute', null), 'executing');
assert.strictEqual(normalizeStateStatus('In progress', null), 'executing');
assert.strictEqual(normalizeStateStatus('In progress', null), 'executing');
assert.strictEqual(normalizeStateStatus(' Paused ', null), 'paused');
assert.strictEqual(normalizeStateStatus('Paused', null), 'paused');
assert.strictEqual(normalizeStateStatus('Stopped', null), 'paused');
assert.strictEqual(normalizeStateStatus('stopped', null), 'paused');
assert.strictEqual(normalizeStateStatus('Discussing', null), 'discussing');
assert.strictEqual(normalizeStateStatus('Verifying', null), 'verifying');
assert.strictEqual(normalizeStateStatus('Completed', null), 'completed');
assert.strictEqual(normalizeStateStatus('Done', null), 'completed');
assert.strictEqual(normalizeStateStatus('done', null), 'completed');
assert.strictEqual(normalizeStateStatus('Complete', null), 'completed');
assert.strictEqual(normalizeStateStatus('Complete ✓', null), 'completed');
assert.strictEqual(normalizeStateStatus('Complete✔', null), 'completed');
});
test('rows 26-27: branch-order artifacts are preserved byte-for-behaviour', () => {
// `verif` outranks `complete` (pinned by tests/state.test.cjs's
// advance-plan case-5 comment); `planning` outranks `complete`.
assert.strictEqual(normalizeStateStatus('Phase complete — ready for verification', null), 'verifying');
assert.strictEqual(normalizeStateStatus('Planning complete', null), 'planning');
});
test('rows 29-30: unknown fallback and the pausedAt force are unchanged', () => {
assert.strictEqual(normalizeStateStatus(null, null), 'unknown');
assert.strictEqual(normalizeStateStatus('', null), 'unknown');
assert.strictEqual(normalizeStateStatus('Executing Phase 5', '2026-09-01'), 'paused');
});
test('row 31: statuses with no branch today stay verbatim', () => {
assert.strictEqual(normalizeStateStatus('Awaiting next milestone', null), 'Awaiting next milestone');
assert.strictEqual(normalizeStateStatus('Defining requirements', null), 'Defining requirements');
assert.strictEqual(normalizeStateStatus('Active', null), 'Active');
});
test('row 32: trailing prose after a vocabulary form is executor-authored and passes through', () => {
// Same discipline as #1070's KNOWN_STATUS_PATTERNS: "Complete but needs
// manual QA" is NOT a template default. The anchored vocabulary only
// recognizes the whole-field value.
assert.strictEqual(normalizeStateStatus('Executing Phase 5 — final stretch', null), 'Executing Phase 5 — final stretch');
assert.strictEqual(normalizeStateStatus('Complete but needs manual QA', null), 'Complete but needs manual QA');
});
});