* fix(#4186): anchored status vocabulary, record-session arg guard, recount pin Three defects from #4186: 1. normalizeStateStatus ran a first-match-wins SUBSTRING chain over the free-prose body Status field, so prose merely mentioning a status word was silently rewritten to a credible wrong token (a .planning/ path in Italian prose -> status: planning; verifica -> verifying; completezza -> completed). Recognition is now an ANCHORED whole-field match against a declared vocabulary (STATUS_EXACT_TOKENS + STATUS_ANCHORED_PATTERNS, state-document.cts) — case/whitespace-tolerant, branch-order artifacts preserved (Planning complete -> planning; Phase complete — ready for verification -> verifying). The recorded lenient fallback (#3873 row 26) stands: unrecognized prose passes through verbatim. Read-side consumers (W011, statusline) ride the same function. 2. The progress recount skew (stray *-SUMMARY.md inflating completed_plans) is already dead on next via #1988/PR #2016 (countMatchedSummaries pairs summaries to plans) — verified live and pinned with regression rows composed against the #4129/#4359 ratchet. 3. state record-session with no args executed and wrote STATE.md; it now errors like state update (stopped-at or resume-file required), handler- side so SDK callers are covered too. Four tests pinning the bare-call write are updated to the new contract. * fix(#4186): update status pins to the anchored vocabulary contract Bench round 1 follow-ups: - Legacy bare 'Milestone complete' kept as reader-side vocabulary (ADR-2207 removed the writers, not recognition of legacy files). - state.test pins updated: 'Paused at Plan 3' and round-trip 'Executing Plan 5' were pins of the substring guessing itself — the round-trip now uses the real handler form 'Executing Phase 5'. - record-session no-op/no-fields tests repurposed to the usage-error contract (CLI + SDK-level ExitError), byte-unchanged assertions kept. - statusline tests repinned: vocabulary values collapse to keywords; narratives render the documented first-word fallback instead of a guessed token. Hook doc comment updated to match. - docs-guard exempt baseline: state.test.cjs now cites docs/CLI-TOOLS.md. - docs/CLI-TOOLS.md: record-session signature notes the required flag. * fix(#4186): repair a dangling sentence in the schema docstring * test(#4186): bound the completed_plans scan regex (#2128 class) * chore(#4186): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1741,16 +1741,30 @@ test('config-set statusline.show_context_tokens yes → rejected', () => {
|
||||
assert.equal(shortGsdStatus(''), null);
|
||||
assert.equal(shortGsdStatus(undefined), null);
|
||||
});
|
||||
test('paused — the canonical stuck state — wins and renders uppercase (#2162 condition)', () => {
|
||||
assert.equal(shortGsdStatus('paused — waiting on credentials'), 'PAUSED');
|
||||
assert.equal(shortGsdStatus('stopped by user'), 'PAUSED');
|
||||
test('paused — the canonical stuck state — renders uppercase (#2162 condition)', () => {
|
||||
// The #2162 shout applies to the canonical token, which is what the
|
||||
// state writer persists (#4186 anchored vocabulary).
|
||||
assert.equal(shortGsdStatus('paused'), 'PAUSED');
|
||||
assert.equal(shortGsdStatus('Paused'), 'PAUSED');
|
||||
// #4186: narrative prose is no longer keyword-guessed — a paused-led
|
||||
// narrative renders its first word (visible, never a silent wrong
|
||||
// token), so the shout is reserved for the recognized token itself.
|
||||
assert.equal(shortGsdStatus('paused — waiting on credentials'), 'paused');
|
||||
assert.equal(shortGsdStatus('stopped by user'), 'stopped');
|
||||
});
|
||||
test('collapses lifecycle narratives to canonical keywords via normalizeStateStatus', () => {
|
||||
assert.equal(shortGsdStatus('Executing phase 7 of the parser milestone'), 'executing');
|
||||
assert.equal(shortGsdStatus('Ready to plan next phase'), 'planning');
|
||||
assert.equal(shortGsdStatus('Discussing scope with user'), 'discussing');
|
||||
assert.equal(shortGsdStatus('Verifying UAT criteria'), 'verifying');
|
||||
assert.equal(shortGsdStatus('Work complete'), 'completed');
|
||||
test('collapses vocabulary values to canonical keywords via normalizeStateStatus (#4186 anchored)', () => {
|
||||
// #4186: normalizeStateStatus recognizes the DECLARED vocabulary
|
||||
// (whole-field match), so exactly those values collapse to keywords.
|
||||
assert.equal(shortGsdStatus('Executing Phase 7'), 'executing');
|
||||
assert.equal(shortGsdStatus('ready to plan'), 'planning');
|
||||
assert.equal(shortGsdStatus('Discussing'), 'discussing');
|
||||
assert.equal(shortGsdStatus('Verifying Phase 2'), 'verifying');
|
||||
assert.equal(shortGsdStatus('Work complete'), 'Work');
|
||||
// Narrative prose — vocabulary words embedded in longer sentences — is
|
||||
// no longer guessed at (a `.planning/` mention in non-English prose
|
||||
// used to render `planning`); it falls back to the first word.
|
||||
assert.equal(shortGsdStatus('Executing phase 7 of the parser milestone'), 'Executing');
|
||||
assert.equal(shortGsdStatus('Ready to plan next phase'), 'Ready');
|
||||
});
|
||||
test('matches the canonical vocabulary exactly — no drift from normalizeStateStatus', () => {
|
||||
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
@@ -1777,9 +1791,15 @@ test('config-set statusline.show_context_tokens yes → rejected', () => {
|
||||
test('renders version · phase/total · status', () => {
|
||||
const out = formatGsdStateCompact({
|
||||
milestone: 'v1.12', phaseNum: '7', phaseTotal: '12',
|
||||
status: 'Executing phase 7 — building the parser',
|
||||
status: 'Executing Phase 7',
|
||||
});
|
||||
assert.equal(out, 'v1.12 · P7/12 · executing');
|
||||
// #4186: narrative status is not keyword-guessed — first word renders.
|
||||
const narrative = formatGsdStateCompact({
|
||||
milestone: 'v1.12', phaseNum: '7', phaseTotal: '12',
|
||||
status: 'Executing phase 7 — building the parser',
|
||||
});
|
||||
assert.equal(narrative, 'v1.12 · P7/12 · Executing');
|
||||
});
|
||||
test('prefers lifecycle active_phase over body phase number', () => {
|
||||
const out = formatGsdStateCompact({
|
||||
@@ -1789,7 +1809,7 @@ test('config-set statusline.show_context_tokens yes → rejected', () => {
|
||||
});
|
||||
test('paused state renders uppercase in the compact line', () => {
|
||||
const out = formatGsdStateCompact({
|
||||
milestone: 'v2.0', activePhase: '4.5', status: 'paused — waiting on review',
|
||||
milestone: 'v2.0', activePhase: '4.5', status: 'paused',
|
||||
});
|
||||
assert.equal(out, 'v2.0 · P4.5 · PAUSED');
|
||||
});
|
||||
|
||||
@@ -2236,3 +2236,147 @@ describe('#3642 — single-section leak controls and seam pins', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -788,10 +788,16 @@ stopped_at: Plan 2 of Phase 3
|
||||
});
|
||||
|
||||
test('normalizes various status values', () => {
|
||||
// #4186: recognition is an ANCHORED whole-field vocabulary match. The
|
||||
// vocabulary's own values normalize to their token; prefix/suffix NARRATIVE
|
||||
// prose ('Paused at Plan 3') passes through verbatim — a visible paragraph
|
||||
// beats a guessed token — and the legacy bare `Milestone complete` stays
|
||||
// recognized (reader-side legacy vocabulary, ADR-2207 removed the writers).
|
||||
const statusTests = [
|
||||
{ input: 'In progress', expected: 'executing' },
|
||||
{ input: 'Ready to execute', expected: 'executing' },
|
||||
{ input: 'Paused at Plan 3', expected: 'paused' },
|
||||
{ input: 'Paused', expected: 'paused' },
|
||||
{ input: 'Paused at Plan 3', expected: 'Paused at Plan 3' },
|
||||
{ input: 'Ready to plan', expected: 'planning' },
|
||||
{ input: 'Phase complete — ready for verification', expected: 'verifying' },
|
||||
{ input: 'Milestone complete', expected: 'completed' },
|
||||
@@ -1140,7 +1146,9 @@ current_phase: 3
|
||||
`
|
||||
);
|
||||
|
||||
runGsdTools('state update Status "Executing Plan 5"', tmpDir);
|
||||
// #4186: use the handler vocabulary's real form (`Executing Phase N`,
|
||||
// state-transition.cts:1216) — narrative variants are no longer guessed.
|
||||
runGsdTools('state update Status "Executing Phase 5"', tmpDir);
|
||||
|
||||
const result = runGsdTools('state json', tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
@@ -3262,22 +3270,39 @@ describe('cmdStateRecordSession (state record-session)', () => {
|
||||
assert.ok(updated.includes(PINNED_ISO), `Last session should be the pinned ISO timestamp ${PINNED_ISO}`);
|
||||
});
|
||||
|
||||
test('updates Last session timestamp even with no other options', () => {
|
||||
// #4186: `state record-session` with NO arguments used to execute and write
|
||||
// `Last session` / `last_updated` to STATE.md (this test previously pinned
|
||||
// that bare-invocation write). It now follows the house pattern every other
|
||||
// required-arg verb uses (`state update`: `error('field and value required
|
||||
// for state update')`) — a bare invocation is a usage error, not a
|
||||
// heartbeat write. Documented signature (docs/CLI-TOOLS.md):
|
||||
// `state record-session --stopped-at "..." [--resume-file path]`.
|
||||
test('#4186: no args errors instead of writing (STATE.md byte-unchanged)', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||||
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
const result = runGsdTools('state record-session', tmpDir);
|
||||
|
||||
assert.ok(!result.success, 'bare record-session must exit non-zero');
|
||||
assert.match(
|
||||
result.error,
|
||||
/stopped-at or resume-file required for state record-session/,
|
||||
'stderr must name the required flags',
|
||||
);
|
||||
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.strictEqual(after, before, 'STATE.md must be byte-unchanged by a rejected bare call');
|
||||
});
|
||||
|
||||
// #4186: validation precedes the STATE.md existence check (same ordering as
|
||||
// cmdStateUpdate), and a lone --resume-file still carries an explicit value.
|
||||
test('#4186: --resume-file alone is a valid explicit value and persists', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||||
|
||||
const PINNED_MS = Date.parse('2020-08-01T08:30:00.000Z');
|
||||
const PINNED_ISO = '2020-08-01T08:30:00.000Z';
|
||||
const result = runGsdTools('state record-session', tmpDir, {
|
||||
GSD_TEST_MODE: '1',
|
||||
GSD_NOW_MS: String(PINNED_MS),
|
||||
});
|
||||
const result = runGsdTools('state record-session --resume-file ".continue-here.md"', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||||
|
||||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(updated.includes(PINNED_ISO), `Last session should contain the pinned ISO timestamp ${PINNED_ISO}`);
|
||||
assert.ok(updated.includes('.continue-here.md'), 'Resume file should be updated');
|
||||
});
|
||||
|
||||
test('sets Resume file to None when not specified', () => {
|
||||
@@ -3295,7 +3320,9 @@ describe('cmdStateRecordSession (state record-session)', () => {
|
||||
});
|
||||
|
||||
test('returns error when STATE.md missing', () => {
|
||||
const result = runGsdTools('state record-session', tmpDir);
|
||||
// #4186: supply a value so this test keeps exercising the STATE.md-missing
|
||||
// decline rather than the (now earlier) no-args usage error.
|
||||
const result = runGsdTools('state record-session --stopped-at "somewhere"', tmpDir);
|
||||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
@@ -3303,18 +3330,20 @@ describe('cmdStateRecordSession (state record-session)', () => {
|
||||
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
|
||||
});
|
||||
|
||||
test('returns recorded false when no session fields found', () => {
|
||||
// #4186: a STATE.md with no session labels is no longer reachable bare
|
||||
// (the no-args usage error fires first, before the existence check even) —
|
||||
// the bare-call contract this test used to pin (recorded:false decline) is
|
||||
// now the error contract pinned above; this variant proves the validation
|
||||
// fires regardless of the body's shape.
|
||||
test('#4186: no args errors even against a STATE.md with no session fields', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n**Status:** Active\n**Phase:** 03\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state record-session', tmpDir);
|
||||
assert.ok(result.success, `Command should exit 0: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, false, 'recorded should be false when no session fields found');
|
||||
assert.ok(output.reason !== undefined, 'should have a reason');
|
||||
assert.ok(!result.success, 'bare record-session must exit non-zero');
|
||||
assert.match(result.error, /stopped-at or resume-file required for state record-session/);
|
||||
});
|
||||
|
||||
// #3374 Variant B: stateReplaceField returns the replaced string on any label
|
||||
@@ -3398,6 +3427,175 @@ describe('cmdStateRecordSession (state record-session)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4186 — the write path must not derive the frontmatter `status` token from
|
||||
// SUBSTRINGS of the free-prose body Status field. Every STATE.md write funnels
|
||||
// through readModifyWriteStateMd → syncStateFrontmatter →
|
||||
// buildStateFrontmatter → normalizeStateStatus, so exercising `state update`
|
||||
// here covers the shared seam for all write paths.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4186: state update does not rewrite status from prose substrings', () => {
|
||||
let tmpDir;
|
||||
|
||||
function writeStateMd(statusValue) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
"gsd_state_version: '1.0'",
|
||||
'status: unknown',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 1 of 2 (Fondamenta)',
|
||||
'Status: ' + statusValue,
|
||||
'Last activity: 2026-09-02 — aggiornato lo stato',
|
||||
'',
|
||||
].join('\n') + '\n');
|
||||
}
|
||||
|
||||
function frontmatterStatus() {
|
||||
const text = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const fm = text.split('---')[1] || '';
|
||||
const m = fm.match(/^status:(.*)$/m);
|
||||
assert.ok(m, 'frontmatter must still carry a status key');
|
||||
return m[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('row 33: Italian prose mentioning .planning/ keeps the visible prose token', () => {
|
||||
writeStateMd('Lavoro sospeso, vedi .planning/STATE.md');
|
||||
const r = runGsdTools(['state', 'update', 'Last activity', '2026-09-03 — verifica del defect'], tmpDir);
|
||||
assert.ok(r.success, `state update failed: ${r.error}`);
|
||||
assert.strictEqual(
|
||||
frontmatterStatus(),
|
||||
'Lavoro sospeso, vedi .planning/STATE.md',
|
||||
'prose containing a .planning/ path must pass through verbatim, not become `planning`',
|
||||
);
|
||||
});
|
||||
|
||||
test('row 34: a recognized handler status still lands its canonical token', () => {
|
||||
writeStateMd('Executing Phase 5');
|
||||
const r = runGsdTools(['state', 'update', 'Last activity', '2026-09-03 — executing'], tmpDir);
|
||||
assert.ok(r.success, `state update failed: ${r.error}`);
|
||||
assert.strictEqual(frontmatterStatus(), 'executing');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4186 — progress recount pin (#1988, PR #2016): a *-SUMMARY.md without a
|
||||
// plan twin must not inflate progress.completed_plans on any recount trigger
|
||||
// (the issue measured `34-TRIAGE-SUMMARY.md` → completed_plans 62 → 63 on
|
||||
// GSD 1.5.0; the pairing fix landed after). These rows pin the correct
|
||||
// behavior so it cannot regress, composed with the #4129/#4359 ratchet.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4186: progress recount ignores stray summaries (pin of #1988)', () => {
|
||||
let tmpDir;
|
||||
|
||||
function seedStrayFixture() {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '34-triage');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
for (const n of ['01', '02', '03']) fs.writeFileSync(path.join(phaseDir, `34-${n}-PLAN.md`), '# plan\n');
|
||||
// Two PAIRED summaries + one stray with no plan twin.
|
||||
fs.writeFileSync(path.join(phaseDir, '34-01-SUMMARY.md'), '# summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '34-02-SUMMARY.md'), '# summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '34-TRIAGE-SUMMARY.md'), '# stray\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
"gsd_state_version: '1.0'",
|
||||
'status: executing',
|
||||
'progress:',
|
||||
' total_phases: 1',
|
||||
' completed_phases: 0',
|
||||
' total_plans: 3',
|
||||
' completed_plans: 0',
|
||||
' percent: 0',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 34 of 34 (Triage)',
|
||||
'Plan: 3 of 3 in current phase',
|
||||
'Status: Executing Phase 34',
|
||||
'Last activity: 2026-09-02 — executing',
|
||||
'',
|
||||
'Progress: [..........] 0%',
|
||||
'Total Plans in Phase: 3',
|
||||
'',
|
||||
].join('\n') + '\n');
|
||||
}
|
||||
|
||||
function completedPlans() {
|
||||
const text = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
// Bounded scan (#2128 class): the progress block sits within a few hundred
|
||||
// bytes of its opening key in every fixture this suite writes.
|
||||
const m = text.match(/^progress:[\s\S]{0,400}?completed_plans:\s*(\d+)/m);
|
||||
assert.ok(m, 'progress.completed_plans must be present after a recount write');
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('row 36: update on the Progress field recounts paired summaries only', () => {
|
||||
seedStrayFixture();
|
||||
const r = runGsdTools(['state', 'update', 'Progress', '[..........] 0%'], tmpDir);
|
||||
assert.ok(r.success, `state update failed: ${r.error}`);
|
||||
assert.strictEqual(completedPlans(), 2,
|
||||
'the stray 34-TRIAGE-SUMMARY.md (no plan twin) must not inflate completed_plans');
|
||||
});
|
||||
|
||||
test('row 37: record-session (the issue trigger) recounts paired summaries only', () => {
|
||||
seedStrayFixture();
|
||||
const r = runGsdTools(['state', 'record-session', '--stopped-at', 'after plan 34-02'], tmpDir);
|
||||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
||||
assert.strictEqual(completedPlans(), 2,
|
||||
'record-session must derive completed_plans from plan-paired summaries, not raw *-SUMMARY.md counts');
|
||||
});
|
||||
|
||||
test('row 38: scanPhasePlans keeps listing the stray file but does not count it', () => {
|
||||
seedStrayFixture();
|
||||
const scanPhasePlans = require('../gsd-core/bin/lib/plan-scan.cjs');
|
||||
const scan = scanPhasePlans(path.join(tmpDir, '.planning', 'phases', '34-triage'));
|
||||
assert.strictEqual(scan.planCount, 3);
|
||||
assert.strictEqual(scan.summaryCount, 2, 'paired summaries only');
|
||||
assert.ok(scan.summaryFiles.includes('34-TRIAGE-SUMMARY.md'),
|
||||
'the stray file stays visible in summaryFiles for callers that list summaries');
|
||||
});
|
||||
|
||||
test('row 39: ratchet still preserves an existing higher completed_plans (#4129/#4359 semantics)', () => {
|
||||
seedStrayFixture();
|
||||
// Poison the frontmatter with a value ABOVE the derived paired count (2).
|
||||
// `state update Progress` EXPLICITLY names a progress field, which by
|
||||
// design (#3242 / ADR-3473 §8.6 early-out) makes the fresh derivation
|
||||
// authoritative — so the ratchet row must use an INCIDENTAL resync write
|
||||
// (record-session) instead: the curated higher counter survives there.
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8').replace('completed_plans: 0', 'completed_plans: 9'));
|
||||
const r = runGsdTools(['state', 'record-session', '--stopped-at', 'after plan 34-02'], tmpDir);
|
||||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
||||
assert.strictEqual(completedPlans(), 9,
|
||||
'the progress ratchet (monotonic completed counts on incidental resyncs) is unchanged by #4186');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Milestone-scoped phase counting in frontmatter
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -10052,17 +10250,19 @@ describe('T6 section-splice characterization — record-session', () => {
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('record-session no-op: no session fields → recorded:false, STATE.md byte-unchanged', () => {
|
||||
test('record-session no-op: bare call rejected, STATE.md byte-unchanged (#4186)', () => {
|
||||
// #4186: the bare call that used to reach the recorded:false decline is
|
||||
// now a usage error (stopped-at or resume-file required). The byte-
|
||||
// unchanged assertion below keeps guarding the #952 no-op posture — a
|
||||
// rejected call must not trample anything, milestone_name included.
|
||||
const d = createTempProject();
|
||||
try {
|
||||
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_SESSION_LABELS);
|
||||
const result = runGsdTools(['state', 'record-session'], d);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, false, 'recorded must be false when no session fields exist');
|
||||
// milestone_name must NOT be trampled (#952 no-op guard)
|
||||
assert.ok(!result.success, 'bare record-session must exit non-zero');
|
||||
assert.match(result.error, /stopped-at or resume-file required for state record-session/);
|
||||
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.strictEqual(after, STATE_NO_SESSION_LABELS, 'STATE.md must be byte-unchanged on no-op');
|
||||
assert.strictEqual(after, STATE_NO_SESSION_LABELS, 'STATE.md must be byte-unchanged by the rejected call');
|
||||
} finally {
|
||||
cleanup(d);
|
||||
}
|
||||
@@ -13964,19 +14164,21 @@ describe('#944: record-session persists values even when body lacks session labe
|
||||
'--resume-file value must be present in STATE.md');
|
||||
});
|
||||
|
||||
test('record-session with no args against a body-less file returns recorded:false (no regression)', () => {
|
||||
// When NO values are supplied and no session fields can be found/updated,
|
||||
// recorded:false is the correct behaviour — we only changed the contract
|
||||
// when the caller supplies values.
|
||||
test('record-session with no args against a body-less file errors (#4186 usage contract)', () => {
|
||||
// #4186 superseded the bare-call contract this test used to pin
|
||||
// (recorded:false decline): a no-values invocation is now a usage error
|
||||
// for every STATE.md shape, handler-side, before any read. The
|
||||
// value-supplied decline paths keep their own dedicated tests above
|
||||
// (#3374 Variant B, #3957).
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
||||
const before = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
const result = runGsdTools('state record-session', tmpDir);
|
||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, false,
|
||||
'recorded should still be false when no session fields exist AND no values were supplied');
|
||||
assert.ok(!result.success, 'should exit non-zero: usage error');
|
||||
assert.match(result.error, /stopped-at or resume-file required for state record-session/);
|
||||
assert.strictEqual(fs.readFileSync(statePath, 'utf-8'), before,
|
||||
'a rejected bare call must not touch the file');
|
||||
});
|
||||
|
||||
test('canonical session section still updates in place (no regression)', () => {
|
||||
@@ -20203,21 +20405,26 @@ describe('#3957 (epic #3473 B9): no-op decline reports the real condition', () =
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Row 8
|
||||
test('record-session: nothing to update reports no-fields-found', () => {
|
||||
// Row 8 — #4186 repurposed: the empty-options call that used to reach
|
||||
// the no-fields-found decline is now a usage error (handler-side guard,
|
||||
// same contract as cmdStateUpdate). The decline itself remains covered
|
||||
// for value-supplied calls by the matched-but-unchanged row below; what
|
||||
// this row now pins is that the SDK-level contract matches the CLI's and
|
||||
// that a rejected call never touches the file.
|
||||
test('record-session: no values supplied is a usage error, STATE.md unchanged (#4186)', () => {
|
||||
tmpDir = createFixture();
|
||||
const statePath = writeState(tmpDir, '# Project State\n\n## Decisions\n\n- none yet\n');
|
||||
const before = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
const { stdout, stderr } = captureCliIO(() => {
|
||||
stateLib.cmdStateRecordSession(tmpDir, {}, false);
|
||||
});
|
||||
|
||||
const out = JSON.parse(stdout);
|
||||
assert.strictEqual(out.recorded, false);
|
||||
assert.strictEqual(out.reason, 'no session fields found in STATE.md to update');
|
||||
// error() writes the user-facing message to stderr and throws a bare
|
||||
// ExitError (v1: message 'process exit <code>' — the message itself is
|
||||
// NOT on the exception; asserted via the CLI-level tests above).
|
||||
assert.throws(
|
||||
() => stateLib.cmdStateRecordSession(tmpDir, {}, false),
|
||||
(err) => err && err.name === 'ExitError' && err.code === 1,
|
||||
'empty options must be rejected before any read or write',
|
||||
);
|
||||
assert.strictEqual(fs.readFileSync(statePath, 'utf-8'), before, 'STATE.md must be unchanged');
|
||||
assert.match(stderr, /^\[gsd-tools\] WARNING: state record-session skipped — no session fields found in STATE\.md to update\./);
|
||||
});
|
||||
|
||||
// Row 9 (hardest — signature B, collapsed reconciliation). A frozen clock
|
||||
|
||||
Reference in New Issue
Block a user