Files
msd-core/tests/state-contract.test.cjs
0xdhx 472f585f7c fix(#3726)!: require --confirm before milestone complete mutates (#3774)
* fix(#3726): require --confirm before milestone complete mutates

`milestone complete <version>` is a one-way door — ROADMAP.md and
REQUIREMENTS.md archived, every phase directory in the milestone MOVED,
STATE.md rewritten — and ran unconditionally on first invocation through
every invocation path, including `query milestone.complete <version>`,
whose `query` meta-prefix reads as a read-only namespace but performs no
filtering (#167's invocation-compatibility shim + #3243's dotted-form
normalization).

The gate lives on the destructive command itself, not on the `query`
prefix (the prefix is an intentional invocation mechanism, not a
permission boundary — restricting it would break dozens of shipped
workflow callers). Without --confirm and without --dry-run the command
now refuses via error() before reading anything beyond its arg checks,
so an unconfirmed invocation is a guaranteed no-op on disk. --dry-run
still previews with no confirmation needed and is now documented in the
usage block (it was only documented for the sibling archive-quick).
--force keeps its narrow meaning — bypassing the TRUNCATED-scope and
unstarted-phase guards — and does not double as the mutation opt-in.
--confirm follows the existing `phases clear --confirm` idiom in the
same module.

complete-milestone.md's two invocations pass --confirm (the workflow has
gathered explicit user intent by that step). Existing tests get
--confirm appended — pre-change behavior is exactly confirmed behavior —
and a #3726 regression block covers: refusal + full-tree byte-identity
on both invocation forms, --force not satisfying the gate, --dry-run
still passing without confirmation, and --confirm proceeding. The
refusal tests fail against pre-fix code (negative control run).

Fixes #3726

* docs(#3726): document the --confirm requirement in CLI-TOOLS and COMMANDS

Cross-AI review of the fix diff (codex, pre-create) caught three shipped
doc sites still instructing the now-refused bare invocation: the
CLI-TOOLS.md milestone-complete synopsis + flag table, and COMMANDS.md's
two guard-override instructions (`--force` alone now refuses without
--confirm). Localized CLI-TOOLS copies already lag the English synopsis
(no --force/--dry-run either) and follow the translation pipeline, not
this fix.

* chore(#3726): set changeset fragment pr to 3774

* test(#3726): confirm-gate CI repairs — QA scenario caller + growth ack

Two CI reds from the --confirm gate, both this branch's own misses:

- tests/qa/scenarios/milestone-rollover.json invoked `milestone complete
  1.0 --force` as a JSON arg-array fixture — a caller shape the test
  sweep (which grepped runGsdTools/runSdkQuery in tests/*.cjs) never
  enumerated. Adds --confirm; the scenario's boundary-crossing contract
  is otherwise untouched.
- complete-milestone.md's +420-byte --confirm note trips the
  emitted-attribution growth ratchet. Acknowledged as a #3726 append to
  the existing complete-milestone.md entry in
  3409-unreachable-guard-arms.json (two ack sources may never name the
  same path, per that fragment's own precedent).

Local: lint-emitted-drift-ack ok; loop-walk.qa 115/115 green sandboxed.

* docs(#3726): CLI-TOOLS.md guard-override sentences say --force --confirm

Review Major 1: the truncated-window and unstarted-phase guard paragraphs
still told the reader to "Pass `--force` to override", which now refuses
(--force alone does not satisfy the confirmation gate), while the flag
table 470 lines later said the opposite. Mirror the docs/COMMANDS.md pair
so the file no longer contradicts itself.

* docs(#3726): synopsis renders --confirm and --dry-run as alternatives

Review Nit 1: `milestone complete <version> --confirm [--dry-run]` read as
"a dry run still needs --confirm", the opposite of AC 3. Render the pair
as `(--confirm | --dry-run)` in the CLI-TOOLS.md synopsis and the usage
docblock, and let the flag rows carry the rule.

* test(#3726): pass --confirm in base-added milestone fixtures; re-file the growth ack

Rebase onto next (26 commits) surfaced three tests the gate now refuses:
the #3685 write-flag contract pair in tests/milestone.test.cjs and the
`milestone complete` boundary fixture in tests/state-contract.test.cjs
all invoke the command bare. Each now passes --confirm (a mutating run is
exactly what they assert on).

The +420 byte complete-milestone.md growth ack rode on
3409-unreachable-guard-arms.json, which #3078 swept from next as fully
spent — hence the modify/delete conflict. Re-filed under a fresh fragment
named for this issue, never resurrecting the swept one.

* test(#3726): pin the present-but-falsy arm of the confirmation gate

Review Minor 1: the boundary triple covered absent and present but not
present-but-falsy. The gate is an exact-token match, so --confirm=false
and --confirm=0 refuse today — pinned (canonical + query forms, whole
.planning/ tree byte-identical) so a future `=`-aware or prefix-matching
parser cannot silently turn --confirm=false into a confirmed run of an
irreversible command.

* test(#3726): drop --confirm from dry-run-only invocations

Review Nit 2: --confirm was mass-appended to 14 pre-existing --dry-run
invocations that never needed it, so each stopped standing as incidental
proof that a preview needs no confirmation. Reverted to the pre-PR form;
the dedicated AC-3 test carries the explicit assertion.

* docs(#3726): sync the localized CLI-TOOLS synopsis with the confirm gate

REQ-I18N-02 (docs/features/internationalized-documentation.md) requires
translations to stay synchronized with the English source. The four
localized CLI-TOOLS.md guides still advertised a bare
`milestone complete <version>`, which now exits 1. Render the English
synopsis verbatim — `(--confirm | --dry-run)` plus the `[--force]` and
`[--archive-quick]` flags the translations had also fallen behind on.

* test(#3726): drop --confirm from the remaining preview-only invocations

Round 2 reverted the --confirm appends on --dry-run-only invocations in
tests/milestone.test.cjs, but four more sat in two files the sweep missed:
tests/milestone-archive.test.cjs (three) and
tests/milestone-window-single-owner.test.cjs (one).

Each is a preview run whose whole purpose is to document that a preview
mutates nothing, so `--dry-run ... --confirm` contradicted the semantics
the test exists to pin. Dropping the token restores each as incidental
proof that a preview needs no confirmation; the dedicated AC-3 test keeps
the explicit assertion.

No assertion added, relaxed, or removed — the change is four tokens.

* chore(#3726): migrate the emitted-drift ack from a fragment to a commit trailer

#3954 (ADR-3942) moved emitted-drift acknowledgments out of
tests/emitted-drift-acks/ and into git commit trailers, and the fragment
directory no longer exists on next. The reason this PR's fragment carried
moves verbatim into the Emitted-Drift-Ack-Growth trailer on this commit;
the fragment file is removed rather than resurrected.

Emitted-Drift-Ack-Growth: complete-milestone.md — #3726: +420 bytes (40186 -> 40606). The archive_milestone step's two `milestone complete` invocations now pass the required --confirm flag (the command refuses to mutate without it — the archive is irreversible), with a note explaining the flag and pointing at --dry-run for previews. Deliberate runtime-loaded workflow text for the new gate, not converter drift.

* fix(#3726): name --confirm in the version-required refusal

The documented arg-discovery path (gsd-tools.cjs top-level usage: invoke
the command without args and the error lists what is required) stopped at
`version required for milestone complete (e.g., v1.0)` — one required
argument short. Discovering --confirm took a second round trip through the
gate. The refusal now reads `… — and --confirm to mutate`, pinned by a test
that also asserts the version-less invocation leaves .planning/ untouched.

* test(#3726): pin the milestone complete docs against a silent regression

The changeset is `type: Fixed`, which the docs-required lint exempts, so
nothing in CI would notice a later edit that reinstated the bare-`--force`
override prose or dropped `--confirm` from the synopsis. Four tests in
tests/milestone.test.cjs now pin: the synopsis line in docs/CLI-TOOLS.md
and its four localized mirrors; the `--confirm` flag row; both
guard-override instructions in docs/CLI-TOOLS.md and docs/COMMANDS.md,
by guard name (a substring match on each instruction's `--force
--confirm` text); and — as an identity ratchet over the
milestone-complete sections — every `--force` sentence or clause that
lacks `--confirm`, so a new bare instruction in its own sentence or
clause fails whatever its wording. Named residual: a bare instruction
spliced into the same clause as a compliant one coalesces with it and
passes the ratchet; the by-name pins are what keep the four known
instructions from losing the pairing that way. The file is registered
in scripts/docs-guard-registry.cjs so the pin runs on the PR that
changes those docs, not only after merge.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-29 17:00:45 -04:00

449 lines
21 KiB
JavaScript

'use strict';
/**
* Integration tests for `src/state-contract.cts` — the v1
* `.planning/state.json` best-effort publisher (#3227).
*
* This file owns the CLI / call-site wiring band only: it spawns the real
* `gsd-tools` binary via `runGsdTools` for each of the 11 documented
* step-boundary commands (plus the non-boundary / no-planning / idempotent /
* error-path guard cases) and asserts state.json actually lands on disk
* end-to-end through the real dispatch path. The spawn-free, in-process
* parsing/mapping/degradation/property-based surface — everything that does
* NOT need a child process — lives in the `.unit.` sibling
* `tests/state-contract.unit.test.cjs`, which is also the Stryker mutation
* shard target (`scripts/mutation-matrix.cjs`, `state-contract` entry):
* Stryker's command runner treats one `node --test <file>` invocation as a
* single test costing whatever its slowest case costs, re-run once per
* mutant, so a suite that spawns a child process per case cannot finish
* inside the 15-minute shard cap (#2790 precedent).
*
* Design: .gsd/phase/feat-3227-state-contract/40-design.md
* Test matrix: .gsd/phase/feat-3227-state-contract/50-test-matrix.md
*
* Fixture provenance (CONTRIBUTING.md "Fixture provenance (#2371)"): every
* `.planning/` document shape written by this file's fixture builders is
* derived from the SHIPPED templates the product author wrote —
* `gsd-core/templates/roadmap.md` (`## Phases` checkbox bullets,
* `### Phase N: Name` details with `Plans:` lists, the 4-column and
* milestone-grouped 5-column `## Progress` tables, and the
* `Not started | In progress | Complete | Deferred` status vocabulary) and
* `gsd-core/templates/state.md` (`## Current Position`, `Phase: X of Y
* (Name)`) — never from `state-contract.cts`'s own parsing model.
*
* This module is a NEW leaf; the fixture builders below are local to this
* file rather than reused from `tests/planning-inspect.test.cjs` (a sibling
* document-shape consumer) because the shapes this suite needs diverge
* enough from that file's `## Phase Details` + `Plans:` fixtures that
* sharing would couple two independent test suites to one mutable helper.
*
* These same fixture helpers are also byte-duplicated (not shared) into the
* `.unit.` sibling above, deliberately: that file is the Stryker mutation
* shard target and must stay spawn-free and self-contained, so a `require`
* of this integration file would drag `runGsdTools` and its subprocess seam
* into the shard. The duplication is isolation, not drift.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const { STATE_CONTRACT_VERSION } = require('../gsd-core/bin/lib/state-contract.cjs');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function statePathOf(cwd) {
return path.join(planningDirOf(cwd), 'state.json');
}
function writeAbs(fullPath, content) {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
function writeFile(cwd, relPath, content) {
writeAbs(path.join(cwd, relPath), content);
}
function writeRoadmapRaw(cwd, content) {
writeFile(cwd, '.planning/ROADMAP.md', content);
}
function writeRoadmap(cwd, lines, eol = '\n') {
writeRoadmapRaw(cwd, lines.join(eol));
}
function writeState(cwd, frontmatterLines, bodyLines = [], eol = '\n') {
writeFile(cwd, '.planning/STATE.md', ['---', ...frontmatterLines, '---', '', ...bodyLines].join(eol));
}
function readStateJsonRaw(cwd) {
return fs.readFileSync(statePathOf(cwd), 'utf8');
}
// ─── 14. Call-site wiring (integration, real CLI) ──────────────────────────────
function writePassedVerification(tmpDir, phaseDirName, phaseToken) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseDirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${phaseToken}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
return phaseDir;
}
const BOUNDARY_COMMANDS = [
{
label: 'state begin-phase',
argv: ['state', 'begin-phase', '--phase', '2', '--name', 'Hardening', '--plans', '2'],
setup: (tmpDir) => {
// Body-only STATE.md (no frontmatter block) — begin-phase reads its
// preconditions from the `**Current Phase:**`-style body fields.
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'**Current Phase:** 2',
'**Current Phase Name:** Hardening',
'**Total Phases:** 3',
'**Current Plan:** 0',
'**Total Plans in Phase:** 0',
'**Status:** Ready to plan',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** setup',
'',
].join('\n'));
},
},
{
label: 'state planned-phase',
argv: ['state', 'planned-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'],
setup: (tmpDir) => {
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 3\n');
},
},
{
label: 'state advance-plan',
argv: ['state', 'advance-plan'],
setup: (tmpDir) => {
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2024-01-10',
'',
].join('\n'));
},
},
{
label: 'state complete-phase',
argv: ['state', 'complete-phase', '--phase', '2'],
setup: (tmpDir) => {
writeState(tmpDir, ['milestone: v1.0', 'current_phase: 2'], [
'# State', '',
'**Status:** Executing',
'**Last Activity:** 2024-01-15',
'',
]);
},
},
{
label: 'state milestone-switch',
argv: ['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
setup: (tmpDir) => {
writeState(tmpDir, [
"gsd_state_version: '1.0'", 'milestone: v1.0', 'milestone_name: Foundation', 'status: completed',
], [
'# Project State', '', '## Current Position', '',
'Phase: 5 (Foundation) — COMPLETED', 'Plan: 3 of 3',
'Status: v1.0 milestone complete', 'Last activity: 2025-01-01 -- v1.0 shipped', '',
]);
writeRoadmap(tmpDir, ['# Roadmap', '', '## v1.0 Foundation', '', '### Phase 5: Notify', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'config.json'), '{}');
},
},
{
label: 'phase add',
argv: ['phase', 'add', 'A new phase'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, ['# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '']);
},
},
{
label: 'phase add-batch',
argv: ['phase', 'add-batch', '--descriptions', '["One","Two"]'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, ['# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '']);
},
},
{
label: 'phase insert',
argv: ['phase', 'insert', '2', 'An inserted phase'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Depends on:** Nothing', '',
'### Phase 2: Auth', '**Goal:** Authentication', '**Depends on:** Phase 1', '',
]);
},
},
{
label: 'phase remove',
argv: ['phase', 'remove', '3', '--force'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Depends on:** Nothing', '',
'### Phase 2: Auth', '**Goal:** Authentication', '**Depends on:** Phase 1', '',
'### Phase 3: Features', '**Goal:** Core features', '**Depends on:** Phase 2', '',
]);
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '03-features'), { recursive: true });
},
},
{
label: 'phase complete',
argv: ['phase', 'complete', '1'],
setup: (tmpDir) => {
writeRoadmap(tmpDir, [
'# Roadmap', '',
'- [ ] Phase 1: Foundation', '- [ ] Phase 2: API', '',
'### Phase 1: Foundation', '**Goal:** Setup', '**Plans:** 1 plans', '',
'### Phase 2: API', '**Goal:** Build API', '',
]);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n');
const p1 = path.join(planningDirOf(tmpDir), 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(planningDirOf(tmpDir), 'phases', '02-api'), { recursive: true });
writePassedVerification(tmpDir, '01-foundation', '01');
},
},
{
label: 'milestone complete',
// --confirm is the mutation opt-in (#3726) — without it the command
// refuses before touching anything, and this fixture must genuinely
// succeed. --force alone does not imply it.
argv: ['milestone', 'complete', 'v1.1', '--force', '--confirm'],
setup: (tmpDir) => {
// --force bypasses both the TRUNCATED-scope guard and the
// unstarted-phase (no_directory) guard, so this fixture only needs a
// resolvable v1.1 milestone window with at least one phase entry —
// the one precondition `getMilestonePhaseFilter`'s
// `missingExplicitVersion` check enforces unconditionally, before
// --force is ever consulted.
writeRoadmap(tmpDir, ['# Roadmap', '', '## v1.1 Hardening', '', '### Phase 1: Foo', '**Goal:** Ship it', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Executing\n**Current Phase:** 1\n**Last Activity:** 2025-01-01\n');
},
},
];
describe('state contract — call-site wiring (integration, real CLI)', () => {
test('eachBoundaryCommandPublishesTheSnapshot', () => {
for (const { label, argv, setup } of BOUNDARY_COMMANDS) {
const tmpDir = createTempProject();
setup(tmpDir);
const result = runGsdTools(argv, tmpDir);
assert.strictEqual(result.success, true, `${label} fixture must genuinely succeed: ${result.error}`);
const jsonPath = statePathOf(tmpDir);
assert.strictEqual(fs.existsSync(jsonPath), true, `${label} must publish state.json`);
const onDisk = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
assert.strictEqual(onDisk.contract, STATE_CONTRACT_VERSION, `${label}: state.json contract must equal STATE_CONTRACT_VERSION`);
cleanup(tmpDir);
}
});
test('nonBoundaryCommandDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), '# State\n\n**Status:** Planning\n');
runGsdTools(['state', 'get', 'status'], tmpDir);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false);
});
test('boundaryCommandUnaffectedWithoutPlanning', (t) => {
const dirA = createTempDir();
t.after(() => cleanup(dirA));
const dirB = createTempDir();
t.after(() => cleanup(dirB));
const argv = ['state', 'begin-phase', '--phase', '1', '--name', 'X', '--plans', '1'];
const resultA = runGsdTools(argv, dirA);
const resultB = runGsdTools(argv, dirB);
assert.strictEqual(resultA.success, resultB.success);
assert.strictEqual(resultA.output, resultB.output);
});
test('idempotentNoOpDoesNotRepublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'),
'# Project State\n\n**Status:** Executing\n**Current Phase:** 1\n**Last Activity:** 2024-01-01\n');
const first = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
assert.ok(first.success, `first complete-phase call failed: ${first.error}`);
assert.ok(fs.existsSync(statePathOf(tmpDir)), 'first (genuine) completion must publish');
const beforeSecondCall = readStateJsonRaw(tmpDir);
const second = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
assert.ok(second.success, `second (idempotent) complete-phase call failed: ${second.error}`);
const afterSecondCall = readStateJsonRaw(tmpDir);
assert.strictEqual(afterSecondCall, beforeSecondCall, 'idempotent no-op must not republish (updated_at must not move)');
});
test('errorPathDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// No STATE.md at all — complete-phase's own precondition check fails.
const result = runGsdTools(['state', 'complete-phase', '--phase', '3'], tmpDir);
assert.ok(result.success, `command should exit 0 with a JSON error envelope: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, 'expected a structured error envelope');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false);
});
// #3227 blocker fix: `updated: []` genuine no-op transitions must not
// publish state.json — a refreshed `updated_at` must always mean something
// on disk actually moved (design doc §40 row 26). Each case below is a
// reproducer confirmed (via direct CLI probing) to reach
// `publishStateContract` on the unfixed code with nothing genuinely written.
test('plannedPhaseNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, [
'# Roadmap', '',
'## Progress', '',
'| Phase | Plans Complete | Status | Completed |',
'|---|---|---|---|',
'| 1. A | 0/1 | Not started | - |',
'',
]);
// ADR-3473 §8.7 (#3872) / test-matrix row 17 (D15): `gsd_state_version`
// is pre-seeded here so this fixture isolates the ONE thing it is meant
// to prove (zero recognized Current Position labels is a genuine no-op)
// from an UNRELATED, already-correct §8.7 behavior — a frontmatter block
// synthesizing `gsd_state_version` for the first time on a document that
// never had one IS a real, reportable addition (row 17/D15, pinned
// separately by "state patch accepts JSON object input from workflows"
// above). Omitting it here would make THIS no-op test fail for a reason
// that has nothing to do with recognized labels.
writeState(tmpDir, ['gsd_state_version: 1.0', 'status: planning'], [
'# Project State', '',
'(no recognized labels here)',
]);
const result = runGsdTools(['state', 'planned-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'], tmpDir);
assert.ok(result.success, `planned-phase fixture must genuinely succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, [], 'expected a genuine no-op (zero recognized Current Position labels)');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'a no-op transition must not publish state.json');
});
test('beginPhaseNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, ['# Roadmap', '']);
fs.writeFileSync(path.join(planningDirOf(tmpDir), 'STATE.md'), [
'# Project State', '',
'(no recognized labels here)',
'',
].join('\n'));
const result = runGsdTools(['state', 'begin-phase', '--phase', '3', '--name', 'Polish', '--plans', '1'], tmpDir);
assert.ok(result.success, `begin-phase fixture must genuinely succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, [], 'expected a genuine no-op (zero recognized body fields)');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'a no-op transition must not publish state.json');
});
// Additional no-op path found while auditing `state advance-plan`
// (cmdStateAdvancePlan, src/state.cts): re-invoking advance-plan while
// already parked at "last plan, ready for verification" reproduces
// byte-identical STATE.md content on the SECOND call. MOVED under
// ADR-3473 §8.7 (#3872): before that change, `reconcileReportedFields`
// compared the transform's OWN reported fields against the FINAL
// persisted bytes, so `Status`/`Last Activity` (values the transform
// always names, which trivially still matched the byte-identical file)
// came back non-empty even though nothing was written. §8.7 replaced
// that with a diff against the transaction's PRE-WRITE snapshot, so a
// genuinely byte-identical second call now correctly reconciles to
// `updated: []` — a true no-op is reported as a true no-op. Publishing
// still does not fire on this call: `cmdStateAdvancePlan` gates on
// `readModifyWriteStateMd`'s own write-happened return value, never on
// `updated.length`, so this remains a no-op-does-not-publish regression
// test — only the expected shape of the (now-correct) `updated` moved.
test('advancePlanNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, ['# Roadmap', '']);
writeState(tmpDir, ['status: planning'], [
'# Project State', '',
'Current Plan: 2', 'Total Plans in Phase: 2', 'Status: Ready to execute', 'Last Activity: 2020-01-01',
'',
'## Current Position',
'Phase: 5', 'Plan: 2 of 2', 'Status: Ready to execute',
'',
]);
const first = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(first.success, `first advance-plan call must genuinely succeed: ${first.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), true, 'first (genuine) advance must publish');
fs.unlinkSync(statePathOf(tmpDir));
const second = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(second.success, `second (no-op) advance-plan call must genuinely succeed: ${second.error}`);
const output = JSON.parse(second.output);
assert.deepStrictEqual(output.updated, [], 'ADR-3473 §8.7: a byte-identical second call is a genuine no-op — `updated` must reconcile to empty, not merely reflect fields the transform always names');
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'the second, no-op advance-plan call must not publish state.json');
});
// Additional no-op path found while auditing `phase complete`
// (cmdPhaseComplete, src/phase.cts): with no STATE.md present, a re-run of
// `phase complete <N>` against an already-completed phase produces a
// `writes[]` whose only entry (ROADMAP.md) is byte-identical to what's
// already on disk — `writePlanningFileSet` applies zero writes on the
// second call, so the fix gates on that applied count rather than
// publishing unconditionally once the verification-gated transaction runs.
test('phaseCompleteReRunWithoutStateNoOpDoesNotPublish', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeRoadmap(tmpDir, [
'# Roadmap', '',
'### Phase 1: Foo', '',
'**Goal:** g', '**Plans:** 1 plans', '',
'Plans:', '- [ ] 01 TBD', '',
'## Progress', '',
'| Phase | Plans Complete | Status | Completed |',
'|---|---|---|---|',
'| 1 | 0/1 | Not started | - |',
'',
]);
if (fs.existsSync(path.join(planningDirOf(tmpDir), 'STATE.md'))) fs.unlinkSync(path.join(planningDirOf(tmpDir), 'STATE.md'));
const phaseDir = path.join(planningDirOf(tmpDir), 'phases', '01-foo');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-SUMMARY.md'), '# Summary');
writePassedVerification(tmpDir, '01-foo', '01');
const first = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(first.success, `first phase complete call must genuinely succeed: ${first.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), true, 'first (genuine) completion must publish');
fs.unlinkSync(statePathOf(tmpDir));
const second = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(second.success, `second (no-op) phase complete call must genuinely succeed: ${second.error}`);
assert.strictEqual(fs.existsSync(statePathOf(tmpDir)), false, 'the second, no-op phase complete call must not publish state.json');
});
});