* 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>
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.
Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
maxRetries (it owns no loop), so the test asserts the option contract
(recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
the helper, not approximated textually at every call site.
Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
destructured and aliased forms; escape hatch is inline
`// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
error-swallowing or name-colliding local teardown helpers) keep the raw call
with an inline eslint-disable + reason.
Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
- assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
STALE ids (a known offender was fixed but not pruned) — identity, not count,
and a ratchet DOWN toward zero.
- assertTightCeiling: a size/length budget whose ceiling must stay within a
grace band of the high-water mark, so budgets may only tighten, never creep.
Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
the remaining six patterns moved from integer baselines to named-set
allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
named filename sets (closes the swap-a-file-keep-the-count blind spot); a
module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).
Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
current high-water mark and an assertTightCeiling anti-creep check added per
tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
are intentionally left as-is — they are not grandfathered creeping budgets.
No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(tests): consolidate Milestone Module — 10 files → 4
Closes#3753
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(changeset): correct fragment format for 3753-consolidate-milestone-tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(changeset): add required frontmatter to 3753 changeset fragment
Adds `type: Fixed` and `pr: 3753` frontmatter so docs-lint passes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): bug-2943 execFileSync timeout 5s→15s for Windows starvation
PR #3754's milestone consolidation reshuffles run-tests.cjs chunk
composition. On Windows/Node 22 under --test-concurrency=4, bug-2943
subprocesses now share concurrency slots with bug-2760-codex-install
subtests (8–15s each), starving past the 5000ms timeout. 15s covers
the observed 13.5s worst case with headroom.
Refs #3753
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>