fix(#2946): run milestone complete unstarted-phase guard independent of STATE (#3081)

* test(#2946): milestone complete unstarted-phase guard fails open on STATE desync

Row 1 of the test matrix: the regression test that fails first. Adds seven
cases to tests/milestone.test.cjs covering the desync, absent, no-file,
--force-override, mismatch-WARNING, fresh-project-noop, and sentinel-skip
behaviors. RED on next: the guard's entire scan is nested inside
`if (stateVersion && stateVersion === version)`, so any STATE.md milestone:
value that does not exactly equal the version argument skips the scan with
no warning — functionally an implicit --force on a one-way-door operation.

* fix(#2946): run milestone complete unstarted-phase guard independent of STATE

The entire ROADMAP phase-directory scan was nested inside
`if (stateVersion && stateVersion === version)`, so any STATE.md milestone:
value that did not exactly string-equal the version argument — a desynced
value, or no milestone: field at all — skipped the scan with no warning,
functionally an implicit --force. The operation the guard fronts is a one-way
door: ROADMAP.md and REQUIREMENTS.md are archived and phase directories are
MOVED into .planning/milestones/<version>-phases/.

The scan was already driven by the version argument through
getMilestonePhaseFilter / extractCurrentMilestone; the STATE match was a
redundant second gate that shadowed and broke it. Decouple: the scan now runs
whenever --force is absent, and a present-but-mismatched STATE milestone:
field emits a WARNING naming both values so the suspicious condition is
visible rather than silent. A fresh project with no Phase headings in the
scoped slice still yields an empty scan (no false positives) — the intent the
STATE-match short-circuit was reaching for, now achieved by the scan itself.

* docs(#2946): document milestone complete --force, --dry-run, and the unstarted-phase guard

The CLI-TOOLS reference signature omitted --force and --dry-run entirely,
and neither the unstarted-phase guard nor its override was documented
anywhere user-facing. Add a flags table and a factual guard description
to the Reference page (CLI-TOOLS.md), and a practical guard note to the
/gsd-complete-milestone How-to (COMMANDS.md) covering what to do when
the guard fires and the new STATE-mismatch WARNING (#2946).

American English per CONTRIBUTING.md language policy.

* fix(#2946): emit STATE-mismatch WARNING as JSON in --json-errors mode

Follow-up to the guard decoupling: a structured caller using --json-errors
parses stderr line-by-line as JSON, so the plain-text WARNING would break
such a parser. Honor getJsonErrorMode() and emit a structured JSON object
({ ok, level, message }) in that mode, plain text otherwise — mirroring
io.cts error()'s JSON shape. Addresses the isolated-review observation
(~45% but credible, since --json-errors is a documented CLI flag).

* fix(#2946): address review — drop JSON-mode WARNING scope creep, tighten test assertions

Standards + spec review findings (code-review two-axis + isolated adversarial):

1. The --json-errors JSON WARNING branch (commit dee719404) was scope creep
   the issue never asked for, AND emitted ok:true for a suspicious-condition
   warning (a category error — a stderr JSON parser keying on ok would treat
   the suspicious state as success), AND its comment falsely claimed to mirror
   io.cts error()'s {ok:false,reason,message} shape. Dropped: the WARNING is
   now plain-text stderr only, matching the existing [gsd-tools] WARNING
   convention (state.cts). The issue asked for 'at minimum warn', not a
   structured JSON surface.

2. The WARNING test asserted on /WARNING/ regex (raw-text matching on stderr
   prose). Tightened to assert on the stable operator-facing tokens — the
   WARNING: marker and both version literals the operator must see — not the
   surrounding formatter prose. Added a paired negative test confirming no
   WARNING is emitted for an absent milestone: field (a missing declaration
   is a normal fresh-project state, not suspicious drift).

* fix(#2946): sanitize STATE milestone value before stderr WARNING interpolation

Security review (minor): stateVersion is read from a user-controlled file
(STATE.md) and is not validated like the CLI version arg. Sanitize before
interpolating into the WARNING — strip ANSI/control chars
(/[\x00-\x1f\x7f]/g -> '?') and truncate to 80 chars — so a corrupted or
hostile STATE.md cannot echo terminal escapes or secret-looking strings
verbatim into a CI log or terminal aggregator (CONTRIBUTING.md security:
secret-looking values in stderr). version is already constrained to
[A-Za-z0-9._-] by ARCHIVE_VERSION_LABEL_RE upstream, so it needs no
sanitization.

* test(#2946): correct stale fixtures that relied on the guard being silently disabled

Four pre-existing tests broke under the #2946 fix because their fixtures
only passed thanks to the bug — the unstarted-phase guard was skipping on
STATE mismatch, so fixtures with missing or non-matching phase directories
slipped through. The tests exercise version-forwarding / version-scoping,
not the guard, so give them legitimate directories:

- milestone.test.cjs #3043: dirs were '103.old'/'104.old'/'108.new' (dot),
  which phaseTokenMatches rejects — renamed to hyphen form. The v3.6 stats
  scoping still yields 1 phase (getMilestonePhaseFilter scopes correctly);
  the guard now sees all three phases as having directories.
- milestone-archive.test.cjs 'returns version in response data': ROADMAP
  listed Phase 1 but no directory was created. Added 01-foundation so the
  scan is satisfied.

Per CONTRIBUTING.md, test-fixture corrections land as their own test:
commit, not bundled into fix: (release hotfix cherry-pick routes by prefix).

* chore(#2946): backfill changeset PR number 3081

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-05 10:42:42 -04:00
committed by GitHub
parent d2e727d3b3
commit 481ac7c71b
6 changed files with 300 additions and 48 deletions

View File

@@ -527,13 +527,25 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
// Guard: prevent marking complete when ROADMAP still lists phases that have
// no directory on disk (disk_status: no_directory). This catches the case
// where the active milestone was erroneously marked complete before phases
// were even started. Only fires when STATE.md confirms the current milestone
// version matches what is being completed — no false positives on fresh
// projects where phases haven't been scaffolded yet.
// were even started. The scan scopes the ROADMAP via the `version` argument
// (getMilestonePhaseFilter / extractCurrentMilestone above) and runs whenever
// --force is absent — a fresh project with no `### Phase N:` headings in the
// scoped slice yields an empty `noDirectoryPhases` and the guard is a no-op,
// so no STATE match is required to avoid false positives.
// Pass --force to override this guard.
//
// #2946: the scan used to be nested inside `if (stateVersion && stateVersion
// === version)`, which silently disarmed the guard whenever STATE.md's
// `milestone:` field was desynced or absent — functionally an implicit
// --force on a one-way-door operation (ROADMAP/REQUIREMENTS archived, phase
// directories MOVED). The STATE field is not the source of truth for which
// phases belong to this milestone; the ROADMAP scoping is. The scan now runs
// unconditionally, and a present-but-mismatched STATE field emits a WARNING
// so the suspicious condition is visible rather than silent.
if (!options.force) {
try {
// Only guard when STATE.md's milestone field matches the version being completed.
// Read STATE.md's milestone field only to detect a suspicious mismatch;
// it no longer gates the scan. (#2946)
let stateVersion: string | null = null;
try {
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
@@ -542,57 +554,75 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
}
} catch {
/* skip */
/* skip — stateVersion stays null, scan still runs */
}
if (stateVersion !== null && stateVersion !== version) {
// #2946: emit a WARNING so the suspicious STATE mismatch is visible
// rather than silently disarming the guard. Plain-text diagnostic on
// stderr, matching the existing [gsd-tools] WARNING convention
// (state.cts). A missing STATE.md `milestone:` field is not warned
// here — the scan still runs, and "no milestone declared" is a normal
// state for a fresh project, not a suspicious drift.
//
// `stateVersion` comes from a user-controlled file (STATE.md) and is
// not validated like the CLI `version` arg (ARCHIVE_VERSION_LABEL_RE).
// Sanitize before interpolating into stderr so ANSI escapes / control
// chars / secret-looking strings cannot be echoed verbatim into a CI
// log or terminal (CONTRIBUTING.md security: secret-looking values in
// stderr). `version` is already constrained to [A-Za-z0-9._-].
const safeStateVersion = stateVersion.replace(/[\x00-\x1f\x7f]/g, '?').slice(0, 80);
process.stderr.write(
`[gsd-tools] WARNING: STATE.md milestone: "${safeStateVersion}" ≠ requested "${version}" — ` +
`running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`,
);
}
if (stateVersion && stateVersion === version) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const noDirectoryPhases: string[] = [];
let pm: RegExpExecArray | null;
const phaseDirEntries = ((): string[] => {
try {
return fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
// real phases — they legitimately have no directory and must not block
// milestone completion. Mirrors the engine-wide sentinel convention
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
// the #1445 /^999/ progress filters). (#1580)
const major = parseInt(phaseNum, 10);
if (major === 0 || major === 999) continue;
const normalized = normalizePhaseName(phaseNum);
// A phase has disk_status: 'no_directory' when no phase directory
// with a matching token exists on disk. Use the same phaseTokenMatches
// helper that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs.
const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
if (!hasDirectory) {
noDirectoryPhases.push(phaseNum);
}
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const noDirectoryPhases: string[] = [];
let pm: RegExpExecArray | null;
const phaseDirEntries = ((): string[] => {
try {
return fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
if (noDirectoryPhases.length > 0) {
error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
);
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
// real phases — they legitimately have no directory and must not block
// milestone completion. Mirrors the engine-wide sentinel convention
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
// the #1445 /^999/ progress filters). (#1580)
const major = parseInt(phaseNum, 10);
if (major === 0 || major === 999) continue;
const normalized = normalizePhaseName(phaseNum);
// A phase has disk_status: 'no_directory' when no phase directory
// with a matching token exists on disk. Use the same phaseTokenMatches
// helper that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs.
const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
if (!hasDirectory) {
noDirectoryPhases.push(phaseNum);
}
}
if (noDirectoryPhases.length > 0) {
error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
);
}
} catch (e) {
// If the error came from our guard, re-throw it; otherwise skip silently.
const message = e instanceof Error ? e.message : String(e);
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
// Phase scan failed or STATE version mismatch — allow completion to proceed.
// Phase scan failed (e.g. ROADMAP unreadable) — allow completion to proceed.
}
}