* test(#3184): failing-first milestone-window single-owner suite Covers the 50 input classes in the phase test matrix: scope classification (genuinely-empty vs truncated vs unscoped vs unreadable), the section-end owner's level boundaries, consumer-output identity per ADR-3180 Decision 4(c), the milestone.complete refusal with negative proof that no directory moved, the version-token boundary defect, drift-guard behavior, and three fast-check properties over document-shaped generators. Committed alone so the remote runner records the failure before the fix lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * refactor(#3184): milestone windowing routes through one owner Three copies of the milestone section-end walk lived in roadmap-parser.cts — two distinct computeSectionEnd function nodes plus an inline third in getMilestonePhaseFilter's versionOverride branch. computeMilestoneSectionEnd is now the sole owner and the other two are deleted, not kept in sync by comment. The whole-repo drift guard found what the epic did not: state.cts held three more re-derivations of the same vocabulary — two byte-identical milestone bounding checks carrying a defect neither reported copy has (no boundary after the version token, so v2.0 matched inside v2.0.1), and a milestone-sectioning predicate. All three route through the owner now. A composition-level duplicate appeared inside this change's own first pass: getMilestonePhaseFilter and cmdMilestoneComplete each re-assembled a window out of the owner's primitives, and had already diverged on whether to skip a closed milestone heading. sliceMilestoneWindow is the one composition. Windows now carry the ADR-3180 SCOPE discriminator, so a truncated window is distinguishable from a genuinely empty milestone — those were output-identical, which is the whole failure class. roadmap analyze emits it (#3165), and milestone complete refuses to archive on anything but COMPLETE rather than pass-all moving every phase directory on disk (#3166). The pass-all degrade is preserved where its premise holds: making the filter deny-all would trade a silent over-inclusive answer for a silent under-inclusive one on the read paths that count with it. extractCurrentMilestone keeps its signature — 200+ affected symbols across 41 files and 25 process flows — and is a one-line wrapper over the scoped owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * fix(#3184): fence-aware phase detection and one heading-selection owner Review fixes from the two orthogonal passes. The blocker: hasPhaseEntries matched ATX phase headings fence-aware via tokenizeHeadings but tested the #2199 bullet form against un-stripped markdown, so a fenced EXAMPLE of the bullet syntax counted as a real phase. A genuinely empty milestone then classified TRUNCATED and milestone complete refused a legitimate archive — a false positive in the destructive direction, worse than the defect this phase set out to fix. Both that path and getMilestonePhaseFilter own pre-existing bullet scan now run on stripFencedCode, since leaving one meant the owner file gave two different answers to the same question. The selection rule — locate, prefer the non-closed heading, else the first — had been written three more times inside the file whose thesis is single ownership. selectMilestoneHeading owns it; all three sites route through it. The copies were behaviorally identical, so this is de-duplication with no observable change, verified by probing that all three paths select the same heading. roadmap analyze emitting a scope no consumer read left #3165's actual symptom alive, so Route 0 in next.md now treats a non-complete scope as scan-failed rather than as a clean empty scan, and the ADR amendment no longer overstates what shipped. Also: the scope refusal moved above the archive-directory create, so a refusal leaves nothing on disk; the versionOverride comment names all four consumers; COMMANDS.md documents the new guard beside its sibling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * test(#2658): exclude the changelog from the malformed-path scan The gate walks every emitted .md/.js/.cjs file in an installed tree and asserts none contains `.claude/.trae/rules` or `.trae/.trae/rules`. CHANGELOG.md ships into that tree, and its #2658 entry quotes both malformed paths while describing the fix that removed them — so the release note documenting the fix trips the fix's own regression test. Red on next before this branch. The installer is correct: a probe over a real --trae --local install found 621 emitted files, exactly one hit, and it was gsd-core/CHANGELOG.md. The scan scope was the defect, not the product. Excluded by exact relative path rather than by loosening the patterns or skipping all markdown — the emitted agent and command markdown is precisely what #2658 was about, so the gate stays strong everywhere it matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * test(#3184): regenerate install-tree fixtures for the shared drift scanner scripts/lib/ ships in the npm package and installer, so extracting the shared tree-walk into scripts/lib/drift-scan.cjs adds one path to every runtime's install tree. Regenerated via npm run gen:install-tree; the delta is exactly that one path per fixture. The two drift guards themselves do not ship (scripts/lint-*.cjs is excluded), so only the extracted library moves. This matches the existing scripts/lib/allowlist-ratchet.cjs precedent, which is likewise a lint-only helper carried in the shipped tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * fix(#3184): restore the #730 sub-milestone boundary and narrow the refusal The remote runner caught two regressions this branch introduced. Both were mine, and neither review pass found them — only running the existing suite did. The version-token boundary. I replaced locateMilestoneHeadings' \b with (?![\w.-]), reasoning that v2.0 matching inside v2.0.1 was the same defect #2562 fixed in isMilestoneShippedInRoadmap. It is not the same question. A milestone state of v8.0 legitimately selects the '## v8.0-B' sub-milestone section over a closed v8.0-A sibling (#730), and \b is what allows it while the stricter boundary forbids it — nine tests in roadmap-phase-fallback said so. Reverted to \b; the state.cts consolidation is now a straight merge with no behavior change, and the v2.0/v2.0.1 ambiguity is left exactly as it was. The ADR amendment and the design doc no longer claim otherwise. The refusal scope. I refused whenever the window was not COMPLETE, but #3166 is about the TRUNCATED window specifically — the heading is found and the section closes before the phase region, so pass-all archives everything. UNREADABLE and UNSCOPED are pre-existing, legitimately handled states, and refusing on them broke 'handles missing ROADMAP.md gracefully' and three archive tests. Narrowed to TRUNCATED; docs corrected to match. One of the new tests was also wrong: its fixture gave the shipped and current milestones' phases the same numeric id, and the filter matches on that id, so it could not have distinguished the two windows. Fixture corrected to exercise what it claims to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * fix(#3184): enumerate drift-scan.cjs for uninstall The installer copies scripts/lib/ wholesale, but uninstall removes an explicit set — deliberately, so a user's own helpers in that directory survive. The extracted drift-scan.cjs was copied in and never enumerated, so it outlived uninstall, left the directory non-empty, and the rmdir that follows failed. Added to GSD_SCRIPTS_LIB_FILES, following allowlist-ratchet.cjs, which is likewise a lint-only helper that ships there and is enumerated. Verified with a real install-then-uninstall into a temp target: scripts/lib/ held exactly the three GSD files and was gone afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * test(#3184): assert install and uninstall agree on scripts/lib and scripts/changeset Found while shipping this phase, and fixed here rather than noted. install() copies scripts/lib/ and scripts/changeset/ into the target WHOLESALE — the comment at the copy site literally says "and any future lib helpers". uninstall() removes them by hardcoded enumeration, deliberately, so a user's own helpers in those directories survive. A wholesale writer paired with an enumerated remover cannot stay in sync by construction: any file added to either directory ships to every user and is then orphaned in their repo forever, since it survives uninstall, leaves the directory non-empty, and the rmdir that follows fails. Nothing reported this. 31,225 tests were green over it. That is the same divergence class this epic exists to delete, sitting in the installer, so it gets the same remedy CLAUDE.md prescribes for it: a parity assertion that fails the moment the two surfaces disagree. The test compares each directory's real contents against its enumeration and names the offending file plus the constant to add it to. Both enumerations are hoisted to module scope and exported, so the test asserts on the actual arrays rather than pattern-matching the installer's source — no allow-test-rule annotation needed. Proven non-vacuous both ways: empty diff on the current tree, correct report when an unenumerated file is injected. scripts/changeset/ turned out to carry the identical defect and is covered too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 * chore(#3184): backfill changeset PR number Also narrows the wording to match the shipped behavior: the refusal fires on a truncated window specifically, not on any non-complete scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,39 @@ node gsd-tools.cjs roadmap analyze
|
||||
node gsd-tools.cjs roadmap update-plan-progress <N>
|
||||
```
|
||||
|
||||
### Milestone window scope (`roadmap analyze`)
|
||||
|
||||
`roadmap analyze` scopes its phase list to the current milestone's section of
|
||||
`ROADMAP.md`. Its JSON output carries a `scope` field describing how much of the
|
||||
intended input that scoping actually saw:
|
||||
|
||||
| `scope` | Meaning |
|
||||
|---|---|
|
||||
| `complete` | The window was computed over the whole intended input. `phase_count: 0` here is a **real** answer — a freshly-declared milestone genuinely has no phases yet. |
|
||||
| `truncated` | The milestone's heading was found, but its window closed before reaching the document's phase region — typically because a closed-milestone heading sits between the active milestone and its `### Phase N:` sections. `phase_count: 0` here is a **non**-answer. |
|
||||
| `unscoped` | No milestone version could be resolved (or its section is absent) on a ROADMAP that does use versioned milestones, so the result is not milestone-scoped. |
|
||||
| `unreadable` | `ROADMAP.md` could not be read. |
|
||||
|
||||
Before this field existed, all four cases produced the same well-formed
|
||||
`phase_count: 0` with no error, so a consumer could not tell a genuinely empty
|
||||
milestone from a scoping failure. Branch on `scope`, not on `phase_count` alone.
|
||||
|
||||
A ROADMAP with no versioned milestone headings at all (the free-form legacy
|
||||
shape) reports `complete`: the whole document *is* the milestone there.
|
||||
|
||||
### `milestone complete` refuses an untrustworthy window
|
||||
|
||||
`milestone complete` archives `ROADMAP.md`/`REQUIREMENTS.md` and **moves phase
|
||||
directories** — a one-way door. When the milestone window's `scope` is
|
||||
`truncated` — the milestone heading was found but its section closes before
|
||||
reaching any phase entries, even though the ROADMAP has phase entries
|
||||
elsewhere — phase scoping cannot be trusted, and the command now refuses
|
||||
rather than falling back to an over-inclusive filter that would archive every
|
||||
phase directory in the project. `unreadable` (no ROADMAP.md at all) and
|
||||
`unscoped` (no section for this version) are pre-existing, legitimately
|
||||
handled states and are not refused here. Pass `--force` to override, the same
|
||||
affordance the unstarted-phase guard uses.
|
||||
|
||||
---
|
||||
|
||||
## Config Commands
|
||||
|
||||
@@ -472,6 +472,8 @@ If any category is non-empty you are prompted with `[R] Resolve` / `[A] Acknowle
|
||||
|
||||
> **Note:** the `deferred-items.md` category is the per-phase SCOPE BOUNDARY log a phase agent writes when it finds a defect it should not fix. It is a different artifact from the `## Deferred Items` section `[A]` writes into `STATE.md`, which records what you acknowledged at close.
|
||||
|
||||
> **Truncated-window guard.** Archiving also refuses when the milestone's ROADMAP window is truncated — `Cannot mark milestone complete: the ROADMAP window for "<version>" is truncated`. This is the case where the milestone's heading is found but its section closes before reaching the roadmap's `### Phase N:` region (typically a closed-milestone heading sitting in between), which previously degraded to an over-inclusive filter and archived *every* phase directory in the project rather than the milestone's own. An unreadable ROADMAP.md or a version with no matching section at all are pre-existing, legitimately-handled states and are not refused here. Same override as below: `gsd-tools milestone complete <version> --force`. A window that is genuinely empty — a freshly-declared milestone with no phases yet — is *not* affected and still completes normally.
|
||||
|
||||
> **Unstarted-phase guard.** Archiving refuses if the milestone's ROADMAP still lists a phase with no phase directory on disk — `Cannot mark milestone complete: ROADMAP lists N unstarted phase(s)`. If a phase was intentionally deferred or merged without a directory, run `gsd-tools milestone complete <version> --force` (the `/gsd-complete-milestone` workflow runs the underlying command without `--force`, so use the CLI directly to override). A `STATE.md` `milestone:` value that does not match `<version>` prints a WARNING and still runs the guard (#2946).
|
||||
|
||||
---
|
||||
|
||||
@@ -295,3 +295,59 @@ plan report as a naming violation — the diagnostic reads non-membership as a d
|
||||
`allPlanFiles`. The general rule: a **diagnostic about file naming** wants the physical set; only a
|
||||
question about outstanding *work* wants the live set. Later phases must make that choice explicitly
|
||||
per call site rather than swapping in `planFiles` mechanically.
|
||||
|
||||
### Amendment 2 — Phase 2 (#3184) validation: the contract held; the copy count was low again
|
||||
|
||||
Decision 2's contract needed **no change** for its second consumer: `SCOPE`'s four values covered
|
||||
every row of the windowing derivation's behavior table, including the two rows the epic's text does
|
||||
not distinguish (a free-form legacy ROADMAP with no versioned milestones is `COMPLETE`, not
|
||||
`UNSCOPED` — whole-document genuinely *is* the milestone there). Phase 2 adds no member and changes
|
||||
no semantics. `src/planning-scope.cts` needed no edit, so Phase 2 carries no `.cts` six-gate ripple.
|
||||
|
||||
**What Phase 2 found.** The epic and this ADR both scope milestone windowing at **three** copies, all
|
||||
inside `roadmap-parser.cts`. Building the Decision 4(a) whole-repo guard found **two more**, in a
|
||||
different module and one function down: `state.cts` `buildStateFrontmatter` and `syncStateFrontmatter`
|
||||
each hand-roll `^#{1,3}\s+(?!Phase\s+\S).*${escapeRegex(version)}` to answer "is this milestone
|
||||
bounded to a versioned ROADMAP heading" — the heading-location half of the derivation, byte-identical
|
||||
to each other. This is Phase 1's finding repeating with a different derivation: **the epic's copy
|
||||
counts are a lower bound derived from the reported issues, and the whole-repo guard is what makes them
|
||||
real.** Both sites now call the owner's `isMilestoneBoundedInRoadmap`, which is a straight
|
||||
consolidation of the two identical `state.cts` regexes onto `locateMilestoneHeadings` with **no
|
||||
behavior change** — which is all it should ever have been.
|
||||
|
||||
**A boundary tightening was tried and reverted.** A first pass at `locateMilestoneHeadings` swapped its
|
||||
`\b` version-token boundary for the stricter `(?![\w.-])` used by `isMilestoneShippedInRoadmap`
|
||||
(#2562), reasoning that `v2.0` should not match inside `v2.0.1` anywhere windowing happens. That broke
|
||||
`extractCurrentMilestoneScoped`'s #730 contract: a milestone STATE of `v8.0` legitimately selects the
|
||||
`## v8.0-B …` active sub-milestone heading over a closed `v8.0-A` sibling (`0` is a word character, `-`
|
||||
is not, so `\b` matches; `(?![\w.-])` does not, because `-` is in its excluded set). `\b` is restored in
|
||||
`locateMilestoneHeadings`; the stricter boundary stays local to `isMilestoneShippedInRoadmap` and to the
|
||||
#730 `detailsVersionBoundary`, which answer a narrower question ("is exactly this milestone shipped" /
|
||||
"which Phase Details section is exactly this one's version token's") than "which heading does this
|
||||
milestone STATE select." The consolidation itself (three `roadmap-parser.cts` copies plus the two
|
||||
`state.cts` copies onto one owner) is behavior-preserving.
|
||||
|
||||
**A composition-level re-derivation, caught in review of this phase's own diff.** Decision 4(c)
|
||||
anticipated a consumer post-*filtering* an owner's result. The shape that actually appeared is its
|
||||
mirror: two sites re-*assembling* a window out of the owner's primitives —
|
||||
`locateMilestoneHeadings` → pick a heading → `computeMilestoneSectionEnd` → slice — in
|
||||
`getMilestonePhaseFilter`'s `versionOverride` branch and in `milestone.cts`'s unstarted-phase guard.
|
||||
Both call the canonical owner at every step, so the drift guard and an owner-level identity test are
|
||||
both green, and the two compositions had **already diverged** on whether to skip a closed milestone
|
||||
heading. Decision 4(c) is therefore read to cover **assembly as well as post-processing**: where a
|
||||
derivation has a composition, the composition is itself an owner. Added as
|
||||
`sliceMilestoneWindow`; both sites route through it.
|
||||
|
||||
**Decision 3's Tier-2 table, re-derived for Phase 2** per its own contingency clause. The row this
|
||||
ADR predicted lands as written, plus two the prediction did not contain:
|
||||
|
||||
| Command surface | Output change |
|
||||
|---|---|
|
||||
| `roadmap analyze` | gains a `scope` field. `phase_count: 0` is still emitted verbatim — what changes is that a sibling field now says whether that zero is an answer. Stated precisely because the first draft of this row claimed the count itself changed, which is not what shipped |
|
||||
| `/gsd:progress --next` Route 0 | `gsd-core/workflows/next.md` treats a non-`complete` scope as scan-failed (warn + fall through to the prior-phase check) instead of looping a phase list the scan could not populate. Without this the new field would be a diagnostic no consumer reads, and #3165's actual symptom — the resume invariant reporting clean because it could not run — would still reproduce |
|
||||
| `milestone complete` | refuses (unless `--force`) when the window's scope is `TRUNCATED` — the milestone heading was found but its section closes before reaching any phase entries, even though the ROADMAP has phase entries elsewhere — instead of pass-all archiving every phase directory on disk (#3166). `UNREADABLE` and `UNSCOPED` are pre-existing, legitimately-handled states (`missingExplicitVersion` errors where that matters; a missing ROADMAP.md has its own documented graceful path) and are not refused here. |
|
||||
| `milestone complete` unstarted-phase guard — **not predicted** | the guard scoped its window by STATE.md's `milestone:` field while the filter beside it scoped by the `version` argument; the two could disagree, and the guard under-detected unstarted phases on the destructive path. Both now use the `version` argument. |
|
||||
|
||||
**Scope note.** Phase 3 (enumeration) inherits a window layer that is now single-owner and
|
||||
scope-carrying; its own guard starts from a green windowing baseline, exactly as Phase 1 left plan
|
||||
counting clean for Phase 3.
|
||||
|
||||
Reference in New Issue
Block a user