Merge branch 'next' into fix/2665-test-env-base-config-location-vars

This commit is contained in:
Tom Boucher
2026-08-08 10:47:09 -04:00
committed by GitHub
39 changed files with 2564 additions and 381 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3209
---
**A truncated milestone window is no longer reported as an empty milestone** — `roadmap analyze` now emits a `scope` field (`complete`/`truncated`/`unscoped`/`unreadable`) so `phase_count: 0` from a genuinely fresh milestone is distinguishable from a window that closed before reaching the roadmap's phase sections, and `milestone complete` refuses to archive on a truncated window instead of moving every phase directory in the project. (#3184)

View File

@@ -181,7 +181,7 @@ Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-t
Shared fail-loud `Result<T>` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`, generated from `src/write-set.cts`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
### Roadmap Parser Module
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `isMilestoneShippedInRoadmap`, `withPhaseSection`). Milestone shipped/active heading classification is owned here (#2562): `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `<summary>` lines only — never a bullet that merely names the version — with the version token boundary-matched so `v2.0` does not match inside `v2.0.1`. `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name so their `planningDir` resolution targets `.planning/workstreams/<ws>/`; omitted, it resolves exactly as before (including the `GSD_WORKSTREAM` fallback). `getMilestonePhaseFilter` exposes `versionScoped`, true only when the returned phase set really is one milestone's — consumers must not read `phaseCount` as a current-milestone denominator otherwise — and `versionSectionFound`, true whenever the requested version's section was located at all. The two differ precisely for a located-but-EMPTY section: it falls through to the zero-count pass-all degrade, which resets `versionScoped` to false, leaving `versionSectionFound` the only surviving evidence that the milestone exists rather than being absent. `missingExplicitVersion` covers the complementary shape (versioned roadmap, no section for this version). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`, and — since #1881 — `unusable-input` for the out-of-band diagnostic) — no `loadConfig`, no other core dependency. An unreadable ROADMAP.md is reported rather than collapsed into the same sentinel as a genuinely absent one; absence itself stays silent, and neither lookup gains a throw (the #2245 audit records that `src/state.cts` removed its defensive try/catch on the strength of `getMilestoneInfo` never throwing). Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `isMilestoneShippedInRoadmap`, `withPhaseSection`). Milestone shipped/active heading classification is owned here (#2562): `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `<summary>` lines only — never a bullet that merely names the version — with the version token boundary-matched so `v2.0` does not match inside `v2.0.1`. `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name so their `planningDir` resolution targets `.planning/workstreams/<ws>/`; omitted, it resolves exactly as before (including the `GSD_WORKSTREAM` fallback). `getMilestonePhaseFilter` exposes `versionScoped`, true only when the returned phase set really is one milestone's — consumers must not read `phaseCount` as a current-milestone denominator otherwise — and `versionSectionFound`, true whenever the requested version's section was located at all. The two differ precisely for a located-but-EMPTY section: it falls through to the zero-count pass-all degrade, which resets `versionScoped` to false, leaving `versionSectionFound` the only surviving evidence that the milestone exists rather than being absent. `missingExplicitVersion` covers the complementary shape (versioned roadmap, no section for this version). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`, and — since #1881 — `unusable-input` for the out-of-band diagnostic) — no `loadConfig`, no other core dependency. An unreadable ROADMAP.md is reported rather than collapsed into the same sentinel as a genuinely absent one; absence itself stays silent, and neither lookup gains a throw (the #2245 audit records that `src/state.cts` removed its defensive try/catch on the strength of `getMilestoneInfo` never throwing). Milestone WINDOWING — which headings bound a milestone — is owned here as of #3184 (epic #3180 Phase 2, ADR-3180 Decision 1): `computeMilestoneSectionEnd` (the section-end walk, formerly duplicated as two distinct nested `computeSectionEnd` functions plus an inline third copy in `getMilestonePhaseFilter`'s `versionOverride` branch), `locateMilestoneHeadings` (heading location, version token boundary-matched with `(?![\w.-])` so `v2.0` does not match inside `v2.0.1` — `\b` does, because `.` is a non-word character), `sliceMilestoneWindow` (the one composition of locate → prefer-non-closed → section-end, so a consumer cannot re-assemble its own window from the primitives), and `isMilestoneBoundedInRoadmap` (the named predicate replacing two byte-identical re-derivations in `state.cts`). `extractCurrentMilestoneScoped` is the real extractor and returns the Planning Scope Module's `ScopedResult`; `extractCurrentMilestone` remains a one-line wrapper over `.value` because its blast radius is CRITICAL (200+ affected symbols, 20 direct callers) and its signature must not move. `getMilestonePhaseFilter` gains a `scope` field: its pass-all degrade is PRESERVED where its premise holds (a genuinely-empty, freshly-declared milestone reports `SCOPE.COMPLETE`) and is now labelled where it does not (`SCOPE.TRUNCATED` when the window reached no phase entries while the document has them), so the destructive consumer — `milestone.complete`, which MOVES phase directories — can refuse instead of archiving every phase directory on disk (#3166). The filter's function behavior is deliberately unchanged: making it deny-all on a non-COMPLETE scope would trade a silent over-inclusive answer for a silent under-inclusive one on the read paths that count with it. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
### Core Utilities Module
Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), plan/summary pairing helpers (`countMatchedSummaries`, `findUnsummarizedPlans`, `findOrphanSummaries`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `extractCanonicalPlanId`, `timeAgo`). `filterPlanFiles`/`filterSummaryFiles` were retired by #3183 (ADR-3180 Decision 2) — `getPhaseFileStats` no longer re-derives plan/summary filename matching locally; it now sources `plans`/`summaries` (plus a `scope` field, `COMPLETE`/`TRUNCATED`/`UNREADABLE`) directly from `scanPhasePlans` (`src/plan-scan.cts`), the single owner of live-plan counting. Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`).

View File

@@ -361,6 +361,21 @@ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-wor
*/
const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
// #3184 — GSD-managed file enumerations for scripts/changeset/ and scripts/lib/
// uninstall. The install-side copy of both directories is wholesale ("copy every
// file present"), so these enumerations MUST be kept in parity with the real
// directory contents or an added file ships on install and then orphans on
// uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
// Hoisted to module scope (and exported below) so tests/install.test.cjs can
// assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
// without source-grepping this file.
const GSD_CHANGESET_FILES = [
'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
'README.md', // documentation only — not user-authored
];
const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs'];
/**
* Resolve a runtime's shared-hooks directory name from its descriptor.
*
@@ -8642,13 +8657,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// Any file NOT in this set is user-owned and must survive uninstall.
// After removing GSD files, attempt to rmdir — if the directory is still
// non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
const GSD_CHANGESET_FILES = [
'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
'README.md', // documentation only — not user-authored
];
const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs'];
// GSD_CHANGESET_FILES / GSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
// tests can assert their parity against the real directory contents.
const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
if (fs.existsSync(changesetUninstallDir)) {
let removedChangeset = 0;
@@ -13482,6 +13492,10 @@ module.exports = {
// #3023 — shared hook bundle directory name, descriptor-driven
SHARED_HOOKS_DIR_DEFAULT,
resolveSharedHooksDirName,
// #3184 — uninstall-side GSD-managed file enumerations, exported for
// parity assertions against the wholesale-copy source directories
GSD_CHANGESET_FILES,
GSD_SCRIPTS_LIB_FILES,
convertSlashCommandsToCodexSkillMentions,
convertClaudeCommandToCodexSkill,
convertClaudeCommandToKimiSkill,

View File

@@ -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

View File

@@ -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).
---

View File

@@ -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.

View File

@@ -104,11 +104,22 @@ Illustrative bash:
```bash
INCOMPLETE_PHASE=""
ROADMAP_JSON=$(gsd_run query roadmap.analyze)
ROADMAP_SCOPE=$(echo "$ROADMAP_JSON" | jq -r '.scope // "complete"')
if [ $? -ne 0 ] || [ -z "$ROADMAP_JSON" ]; then
echo "⚠ WARNING: resume-incomplete-phase scan could not run (roadmap.analyze failed)." >&2
echo " The incomplete-phase invariant (#160) could not be verified." >&2
echo " Proceeding to prior-phase completeness check — review project state carefully." >&2
# Fall through to prior_phase_completeness rather than silently skipping
elif [ "$ROADMAP_SCOPE" != "complete" ]; then
# #3184/#3165: roadmap.analyze succeeded and returned a well-formed document,
# but its milestone window did not see all of its input, so `.phases[]` is a
# NON-answer rather than a real empty. Looping it would run the invariant over
# a phase list the scan could not populate and report "clean" — the silent
# disarm #3165 reports. Treated as scan-failed, same as an outright failure.
echo "⚠ WARNING: resume-incomplete-phase scan could not be scoped (roadmap.analyze scope: $ROADMAP_SCOPE)." >&2
echo " The milestone window did not cover the whole ROADMAP, so the phase list is incomplete." >&2
echo " The incomplete-phase invariant (#160) could not be verified — review project state carefully." >&2
# Fall through to prior_phase_completeness rather than silently passing
else
for PHASE_NUM in $(echo "$ROADMAP_JSON" | jq -r '.phases[] | (.number // .phase_number // empty)'); do
PHASE_JSON=$(gsd_run query find-phase "$PHASE_NUM")
@@ -338,6 +349,7 @@ Resume with: `/gsd:progress --next --auto` once resolved.
- [ ] `--no-resume`: Route 0 skipped, prior_phase_completeness defer prompt runs as before
- [ ] `--force`: everything skipped (Gates, Route 0, prior_phase_completeness) → straight to `determine_next_action`
- [ ] Scan uses `gsd_run` (canonical resolver form); errors are surfaced rather than suppressed
- [ ] A `roadmap.analyze` result whose `scope` is not `complete` is treated as scan-failed (warn + fall through), never as a clean empty scan (#3184/#3165)
- [ ] Predicate is plans-without-summaries (`plans.length > summaries.length`) — consistent with `determine_next_action` Route 4
- [ ] Next action correctly determined from routing rules
- [ ] Command invoked immediately without user confirmation

View File

@@ -112,7 +112,7 @@
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
"lint:fix": "eslint . --fix",
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs",
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
"lint:descriptions": "node scripts/lint-descriptions.cjs",

278
scripts/lib/drift-scan.cjs Normal file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env node
'use strict';
/**
* Shared anti-divergence scanner machinery (epic #3180, ADR-3180 Decision 4).
*
* Extracted from `scripts/lint-plan-count-drift.cjs` (#3183) so that
* `scripts/lint-milestone-window-drift.cjs` (#3184) and any future
* `scripts/lint-<derivation>-drift.cjs` guard consume ONE tree-walk /
* root-confinement / literal-tokenizer / sanitizer implementation instead of
* each copying it verbatim — the exact generative-fix-divergence class this
* epic exists to remove, now applied to the guards themselves (see
* `.gsd/phase/refactor-3184-milestone-window-single-owner/40-design.md`,
* "Rejected: let the new drift guard copy Phase 1's tree-walk /
* root-confinement / sanitizer").
*
* `lint-plan-count-drift.cjs` re-exports every symbol it exported before this
* extraction (`readRegexLiteralAt`, `MAX_REGEX_LITERAL_LEN`, `isInsideRoot`,
* `sanitizeForReport`), so `tests/plan-count-single-owner.test.cjs` — the
* ReDoS and root-confinement regression net for this exact machinery —
* continues to pass unchanged and is the regression net for this move too.
*/
const fs = require('node:fs');
const path = require('node:path');
// Longest regex literal this scanner will consider, in characters. Real
// plan/summary filename filters (and milestone-window regex literals) are far
// shorter; the bound is what keeps the scan linear. The tokenizer restarts at
// every `/` on the line (so that a literal preceded by a stray unpaired `/`
// is still found, matching the previous regex's "find anywhere" behaviour),
// which without a per-literal bound would be quadratic on a pathological
// line. With it the whole-line cost is O(n * MAX_REGEX_LITERAL_LEN) with no
// backtracking at all.
const MAX_REGEX_LITERAL_LEN = 400;
// Directory names this scanner never descends into or reports out of —
// `.git` (repo internals, e.g. a persisted CI token in `.git/config`),
// `node_modules` (thousands of third-party files, none of them authored
// source), `dist` (build output). Named once and used at BOTH skip sites
// below: the cheap `entry.name` fast path in `walk`, and the resolved-path
// component check in `isUnderSkippedDir` — a symlink whose OWN name is not
// in this set but whose target resolves through a directory that IS (e.g.
// `src/g -> ../.git`, `src/nm -> ../node_modules`) must still be skipped, or
// the name-only check is a trivial bypass.
const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', '.git']);
/**
* Read the JS regex literal starting at `line[start]` (which must be `/`).
* Returns `{ text, end }` — `text` includes the delimiters and any trailing
* flags, `end` is the index one past the literal — or null if no literal
* closes within MAX_REGEX_LITERAL_LEN characters.
*
* Single left-to-right pass, no backtracking. It models the two constructs a
* backtracking pattern gets wrong, which is why this is a tokenizer and not a
* regex:
* - `\x` escapes consume BOTH characters, so an escaped `\/` never
* terminates the literal;
* - inside a `[...]` character class a bare `/` does NOT terminate, so
* `/PLAN[\\/].*\.md$/` is one literal rather than two fragments. The
* previous regex silently MISSED every re-derivation using a
* cross-platform path-separator class for exactly this reason.
*/
function readRegexLiteralAt(line, start) {
if (line[start] !== '/') return null;
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
let inClass = false;
for (let i = start + 1; i < limit; i++) {
const ch = line[i];
if (ch === '\\') {
i++; // escape consumes the next character, whatever it is
continue;
}
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines
if (ch === '[') {
inClass = true;
} else if (ch === ']') {
inClass = false;
} else if (ch === '/' && !inClass) {
// Trailing flags are bounded by the SAME `limit` as the literal body
// itself (not `line.length`) — a literal followed by an unbounded run
// of lowercase letters must not make `text` grow past
// MAX_REGEX_LITERAL_LEN either.
let end = i + 1;
while (end < limit && line[end] >= 'a' && line[end] <= 'z') end++;
return { text: line.slice(start, end), end };
}
}
return null;
}
// Symlinks report `isDirectory()`/`isFile()` as false on the Dirent from
// `readdirSync`, so a symlinked `.cts` (or a symlinked directory containing
// one) was previously invisible to this scanner — an evasion of a guard
// whose stated design principle (ADR-3180 Decision 4a) is whole-repo
// discovery with no allowlist. Resolve each entry with `fs.statSync` (which
// follows symlinks) to classify it, skipping broken links. `ctx.visitedRealDirs`
// guards against a symlink cycle sending `walk` into infinite recursion.
//
// Every sibling drift guard in `scripts/` that does NOT import this module
// (`lint-phase-id-drift.cjs`, `lint-package-identity-drift.cjs`,
// `lint-portable-timeout.cjs`, `lint-test-file-count.cjs`,
// `lint-allow-test-rule-refs.cjs`) uses the `Dirent` classification straight
// off `readdirSync` and does NOT follow symlinks at all. This scanner follows
// them so a symlinked source file cannot evade ADR-3180 Decision 4a's
// whole-repo discovery — root confinement (`isInsideRoot` below) is the price
// of doing so: without it, a symlink planted anywhere under a scan directory
// could walk this scanner out to read and report arbitrary files elsewhere on
// disk.
//
// DIRECTORY vs FILE symlinks are confined to two DIFFERENT roots, tracked as
// `ctx.scanDirRoot` (the realpath of the current top-level scan-dir entry,
// e.g. `<realRoot>/src`) vs `ctx.realRoot` (the whole repo):
// - a DIRECTORY symlink is descended ONLY if its resolved realpath is
// inside `ctx.scanDirRoot` — NOT merely inside `ctx.realRoot`. Without
// this, `src/up -> ..` (or `-> <realRoot>`) resolves inside the repo
// root and `walk` descends the ENTIRE repo, reporting violations under
// paths like `tests/not-src.cts` or `docs/other.cts` — files the caller's
// scan-dir list scopes it OUT of. This is a deliberate, fail-CLOSED
// trade-off: a directory symlink pointing elsewhere INSIDE the repo (but
// outside the scan directory) is simply not followed. The alternative —
// descending it — is exactly the whole-repo sweep this rule exists to
// prevent, and a fork PR could use that sweep to redden `lint:ci` on
// files this guard was never meant to read. The narrower rule is worth
// more than the missed edge case.
// - a FILE symlink is still scanned if its resolved realpath is inside
// `ctx.realRoot` (the whole repo, not just the scan directory) — this is
// what keeps `src/alias.cts -> vendor/real.cts` covered (test (f)): an
// aliased file genuinely is part of the compiled surface even when its
// real target lives outside `src/`, and it is still reported under its
// canonical (real) path.
//
// A resolved path is inside a root only if it IS that root or begins with
// root + separator — a plain `startsWith(root)` would also accept a sibling
// directory whose name merely starts with the root's name (`/repo-evil`).
function isInsideRoot(realPath, realRoot) {
return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
}
// True when `realPath` (already confirmed inside `realRoot` by `isInsideRoot`)
// resolves THROUGH a skip-list directory anywhere along its path relative to
// the root — not just when `realPath` itself IS one. This is what closes the
// symlink bypass the `entry.name` fast path alone cannot: `walk` tests
// `entry.name` (the symlink's OWN name in its parent directory), but a
// symlink named something innocuous can still RESOLVE into `.git` /
// `node_modules` / `dist` (`src/g -> ../.git`, `src/leak.cts ->
// ../.git/config`, `src/nm -> ../node_modules`) — `isInsideRoot` alone admits
// all three, because every one of those real paths is still under the root.
function isUnderSkippedDir(realPath, realRoot) {
const rel = path.relative(realRoot, realPath);
return rel.split(path.sep).some((segment) => SKIP_DIR_NAMES.has(segment));
}
// `ctx.scanExt` is a `Set` of file extensions (e.g. `.cts`/`.ts`/`.mts`) the
// caller wants reported — threaded through `ctx` rather than as a positional
// parameter so recursive `walk(full, acc, ctx)` calls stay unchanged.
function walk(dir, acc, ctx) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (SKIP_DIR_NAMES.has(entry.name)) continue; // cheap fast path
let stat;
try {
stat = entry.isSymbolicLink() ? fs.statSync(full) : entry;
} catch {
continue; // broken symlink target
}
let realPath;
try {
realPath = fs.realpathSync(full);
} catch {
continue; // broken symlink target (race, or a link stat() followed but realpath cannot)
}
if (stat.isDirectory()) {
// Directories (symlinked or real) are confined to the CURRENT scan
// directory root, not merely the repo root — see the comment above
// `isInsideRoot` for why (`src/up -> ..` whole-repo sweep).
if (!isInsideRoot(realPath, ctx.scanDirRoot)) continue;
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
if (ctx.visitedRealDirs.has(realPath)) continue; // symlink cycle guard
ctx.visitedRealDirs.add(realPath);
walk(full, acc, ctx);
} else if (stat.isFile() && ctx.scanExt.has(path.extname(entry.name))) {
// Files are confined to the whole repo root — a symlinked FILE whose
// real target lives outside the scan directory but inside the repo
// (e.g. `src/alias.cts -> vendor/real.cts`) is still part of the
// compiled surface and must be scanned.
if (!isInsideRoot(realPath, ctx.realRoot)) continue;
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
if (ctx.visitedRealFiles.has(realPath)) continue; // two symlinks, same real file
ctx.visitedRealFiles.add(realPath);
acc.push(realPath);
}
}
return acc;
}
/**
* Whole-repo tree-walk driver shared by every `lint-<derivation>-drift.cjs`
* guard. Resolves `root`, walks each of `scanDirs` filtered to `scanExt`
* (symlink-following, root-confined, cycle-guarded — see `walk` above), reads
* each discovered file, and calls `onFile(relPath, text)` for it — `relPath`
* is repo-relative and already the file's canonical (real) path, so a
* per-file exemption keyed on `relPath` matches consistently regardless of
* which symlink reached it.
*
* `onFile` returns an array of violation objects (or an empty array / null /
* undefined for "no violations in this file"); `scanTree` flattens them all
* into one returned array. Pure I/O orchestration — detection logic lives
* entirely in the caller's `onFile`.
*/
function scanTree({ root, scanDirs, scanExt, onFile }) {
const violations = [];
let realRoot;
try {
realRoot = fs.realpathSync(root);
} catch {
return violations; // root itself does not exist / is unreadable
}
for (const dir of scanDirs) {
const scanDirPath = path.join(root, dir);
let scanDirRoot;
try {
scanDirRoot = fs.realpathSync(scanDirPath);
} catch {
continue; // scan directory itself does not exist / is unreadable
}
const ctx = { realRoot, scanDirRoot, scanExt, visitedRealDirs: new Set(), visitedRealFiles: new Set() };
for (const file of walk(scanDirPath, [], ctx)) {
const rel = path.relative(realRoot, file);
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch {
continue;
}
const found = onFile(rel, text);
if (found && found.length > 0) violations.push(...found);
}
}
return violations;
}
// A reported fragment AND a reported file path are both attacker-controlled
// source text on a fork PR (a repo can legally track a filename containing
// control bytes, so the path is exactly as attacker-controlled as the
// fragment), and both are written straight to a CI log. Replace C0/C1
// control bytes (ANSI escapes included) with a visible \xNN, AND the
// non-Latin-1 formatting/bidi/line-separator codepoints below with \uNNNN, so
// a crafted literal or filename cannot rewrite the terminal rendering of the
// report or hide/reorder its own text:
// - U+200B-U+200F: zero-width space/joiners and directional marks
// - U+2028/U+2029: Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR (line
// breaks a `\n`-only log scan would not catch)
// - U+202A-U+202E: bidi embedding/override controls (RLO etc.)
// - U+2066-U+2069: bidi isolate controls
function sanitizeForReport(text) {
return text
// eslint-disable-next-line no-control-regex -- the control range IS the target
.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.replace(/[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
}
module.exports = {
SKIP_DIR_NAMES,
isInsideRoot,
isUnderSkippedDir,
walk,
readRegexLiteralAt,
MAX_REGEX_LITERAL_LEN,
sanitizeForReport,
scanTree,
};

View File

@@ -0,0 +1,300 @@
#!/usr/bin/env node
'use strict';
/**
* Anti-divergence drift guard for the milestone-WINDOWING seam
* (epic #3180, issue #3184, ADR-3180 "Planning Semantic Model Single Owner").
*
* `src/roadmap-parser.cts` is the SINGLE canonical owner of "where does a
* given milestone's ROADMAP section begin and end" — `computeMilestoneSectionEnd`,
* `locateMilestoneHeadings`, and `isMilestoneBoundedInRoadmap`. Every other
* module that hand-rolls a heading-level quantifier together with the
* milestone-boundary shape (a non-Phase heading carrying a version token or a
* shipped/active marker) is a re-derivation that can silently drift from the
* owner — the exact defect class #2562 fixed in one copy and never reached
* the other two (design doc: `currentMilestoneRawRanges::computeSectionEnd`
* carried a "keep in sync" comment that was already evidence the risk was
* known, not controlled).
*
* Per ADR-3180 Decision 4(a) this guard discovers call sites by SCANNING THE
* WHOLE `src/` TREE, not by consulting an allowlist of known files — an
* allowlist only measures re-derivations in files someone remembered to
* list. Exemptions below are FUNCTION-SCOPED with a written reason, never a
* bare file allowlist, mirroring `lint-plan-count-drift.cjs`'s precedent.
*
* Detection is intentionally NARROW, mirroring the plan-count-drift and
* phase-id-drift precedents: a line is a re-derivation when it carries BOTH,
* in ONE source line:
* (a) a markdown heading-level quantifier token — `#{N,M}`, e.g. `#{1,3}`,
* `#{2,3}`, `#{2,4}` — inside either a regex literal or a
* quoted/backticked string, AND
* (b) a milestone-window token: either the negative-lookahead phase
* exclusion (`(?!Phase` / `(?!Phase\s+\S)`) or the milestone
* boundary-marker set — a `v\d+\.\d+`-shaped version token appearing
* together with any of the ✅ 📋 🚧 shipped/active markers.
* Token (b) is deliberately narrow: a PHASE-heading regex (`#{2,4}\s*Phase`)
* carries (a) alone, constantly, throughout this codebase (phase-numbering,
* plan-index, wave-scheduling call sites) and must NOT be flagged — it asks
* "where is phase N's heading", a different, already-single-owned question
* (#2121). Only a line that ALSO carries the milestone-boundary shape — the
* one `computeMilestoneSectionEnd`/`locateMilestoneHeadings` compute — is a
* candidate re-derivation of THIS derivation.
*
* Both `(a)` and `(b)` must be readable through JS regex-literal AND
* string/template-literal escaping: the two pre-#3184 `state.cts`
* re-derivations this design is modelled on were
* `new RegExp(\`^#{1,3}\\s+(?!Phase\\s+\\S)...\`)` — i.e. the SAME source
* text as a real `/.../ ` regex literal, just doubly backslash-escaped
* because it lives inside a template literal. `HEADING_QUANTIFIER_RE` and
* `PHASE_LOOKAHEAD_RE` match either escaping level unchanged (no backslash
* appears inside `#{`/`}`/`(?!Phase`'s literal characters); `VERSION_TOKEN_RE`
* explicitly tolerates ONE or TWO backslashes before each `d`/`.` for exactly
* this reason. Regex-LITERAL boundaries (used only to extract a reportable
* `found` fragment, never for detection itself, which tests the raw line) are
* located via the shared `readRegexLiteralAt` tokenizer
* (`scripts/lib/drift-scan.cjs`) — a single left-to-right, no-backtracking
* pass — never a backtracking "find the regex literal" regex (CodeQL js/redos
* runs on `lint:ci`; see that module's own header for the full rationale).
* `readStringLiteralAt` below is the same style, written locally for
* quoted/backticked strings (not shared — `lint-plan-count-drift.cjs` has no
* equivalent need, since its own literal-bearing shape is regex-only).
*
* Owner file (exempt by construction): `src/roadmap-parser.cts` — it not only
* DEFINES this grammar but composes `#{1,3}` with `(?!Phase...)`/marker
* alternations at several internal call sites (`computeMilestoneSectionEnd`,
* `locateMilestoneHeadings`, `extractCurrentMilestoneScoped`'s
* `anyMilestonePattern`/`anyMilestoneOrDetails`) that are the canonical
* implementation, not copies of it.
*
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
* machinery is SHARED with `scripts/lint-plan-count-drift.cjs` via
* `scripts/lib/drift-scan.cjs` (ADR-3180 Decision 4, design doc's own
* "Rejected: let the new drift guard copy Phase 1's tree-walk /
* root-confinement / sanitizer") — see that module for the `isInsideRoot`
* case-sensitivity note, the `walk` symlink-confinement rationale, and the
* `readRegexLiteralAt` ReDoS-avoidance rationale.
*
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the
* sibling drift guards document): a re-derivation whose `(a)`/`(b)` tokens
* are split across two DIFFERENT lines with no single line carrying both is
* not caught by this narrow shape, nor is one routed through dynamic
* dispatch. That is left to code review and the design's identity test
* (ADR-3180 Decision 4b/4c), not this regex.
*/
const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { readRegexLiteralAt, MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
// (a) A markdown heading-level quantifier: `#{N,M}` — e.g. `#{1,3}`,
// `#{2,3}`, `#{2,4}`. Bounded to 1-2 digit levels (real Markdown headings
// never exceed level 6) so this stays a small, fixed, linear test — no
// unbounded quantifier, nothing for CodeQL js/redos to flag.
const HEADING_QUANTIFIER_RE = /#\{\d{1,2},\d{1,2}\}/;
// (b1) The negative-lookahead phase exclusion `computeMilestoneSectionEnd`/
// `locateMilestoneHeadings` use to skip `### Phase N: …` headings while
// scanning for the NEXT milestone boundary.
const PHASE_LOOKAHEAD_RE = /\(\?!Phase\b/;
// (b2) A `v\d+\.\d+`-shaped version token, tolerant of ONE or TWO backslash
// escaping levels (a bare regex literal carries `\d`/`\.` with a single
// backslash; a template-literal regex SOURCE string carries the SAME source
// text doubly-escaped, `\\d`/`\\.`, because the template literal's own
// backslash must itself be escaped in the .cts source) and an OPTIONAL
// capturing group immediately around the digit run (`v(\d+)\.\d+`, the shape
// `roadmap-command-router.cts`'s `MILESTONE_RE` actually uses to capture the
// major version number).
const VERSION_TOKEN_RE = /v\(?\\{1,2}d\+\)?\\{1,2}\.\\{1,2}d\+/;
// (b2) The milestone shipped/active marker set `isClosedMilestoneHeading`/
// `computeMilestoneSectionEnd` test for. `(b)` fires when this appears on the
// SAME line as a VERSION_TOKEN_RE match — a version token alone is not
// milestone-boundary-specific (plenty of non-heading code compares version
// strings), and a marker alone is not either (it can appear in unrelated
// prose-matching code); together, on one line, they are the boundary shape.
const MARKER_EMOJI_RE = /[✅📋🚧]/u;
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
const SCAN_DIRS = ['src'];
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
// The canonical owner defines the grammar; it is exempt by construction (see
// header comment for why its OWN internal composition of these tokens is not
// a re-derivation).
const OWNER_FILE = path.join('src', 'roadmap-parser.cts');
// Per ADR-3180 Decision 4(a): NOT a bare file allowlist — each entry below is
// scoped to the SPECIFIC function asking a documented, DIFFERENT question, so
// an unrelated re-derivation added anywhere else in these same files is still
// caught. Mirrors `lint-plan-count-drift.cjs`'s FUNCTION_SCOPED_EXEMPTIONS
// mechanism.
//
// - roadmap-command-router.cts checkW021: `MILESTONE_RE` CLASSIFIES a
// single heading LINE as "is this a milestone heading, and if so what is
// its major version" for the W021 phase/milestone-prefix-mismatch check
// — it is a per-line classifier consumed one line at a time via
// `content.split('\n')`, with no concept of a section END at all. It
// never computes "where does this milestone's content stop" — the
// question `computeMilestoneSectionEnd` answers — so it cannot diverge
// from that computation; it answers a narrower, different question this
// derivation does not own.
// - verify.cts checkMilestonePrefixMismatches: `sectionRx` ENUMERATES
// every milestone heading in the document to build a list of
// `{version, start, end}` sections (each section's `end` is provisionally
// "rest of document" until the NEXT heading is found, then backfilled) —
// it is answering "what are ALL the milestone sections", to check every
// phase against its OWN enclosing milestone, not "where does THIS ONE
// milestone (the current/asserted one) end" — `computeMilestoneSectionEnd`
// takes a single heading and returns a single boundary; this function
// never calls anything with that shape. (Design brief named this
// `cmdValidateConsistency` — the code actually lives in the sibling
// function `checkMilestonePrefixMismatches`, called from
// `cmdValidateHealth`; `cmdValidateConsistency` itself does not contain
// `sectionRx`. Exempted here under its ACTUAL containing function.) Also:
// `sectionRx` (`/^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim`)
// does not itself carry token (b) as this guard defines it (no
// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption
// currently documents intent rather than suppressing a live match.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])],
[path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])],
]);
// Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s
// TOP_LEVEL_FUNCTION_RE — only a column-0 top-level `function` declaration
// updates the current-function tracker; a nested/arrow function does not
// reset it, matching every FUNCTION_SCOPED_EXEMPTIONS entry above (all
// top-level `function` declarations).
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
/**
* Read the quoted or backtick-delimited string/template literal starting at
* `line[start]` (which must be `'`, `"`, or `` ` ``). Returns `{ text, end }`
* — `text` includes both delimiters, `end` is the index one past the literal
* — or null if no matching close quote is found within MAX_REGEX_LITERAL_LEN
* characters. Same single left-to-right, no-backtracking, escape-aware style
* as the shared `readRegexLiteralAt` (`\x` escapes consume both characters,
* so an escaped quote never terminates the literal early) — written locally
* because `lint-plan-count-drift.cjs` has no equivalent need (its literal
* shape is regex-only), so it does not belong in the shared module.
*/
function readStringLiteralAt(line, start) {
const quote = line[start];
if (quote !== "'" && quote !== '"' && quote !== '`') return null;
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
for (let i = start + 1; i < limit; i++) {
const ch = line[i];
if (ch === '\\') {
i++; // escape consumes the next character, whatever it is
continue;
}
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines in this per-line scan
if (ch === quote) return { text: line.slice(start, i + 1), end: i + 1 };
}
return null;
}
/**
* The first literal (regex OR quoted/backtick string) on `line` whose text
* contains a HEADING_QUANTIFIER_RE match — the "smoking gun" fragment worth
* reporting, mirroring `findRegexLiteralMdMatch`'s role in the sibling guard.
* Falls back to a bounded, trimmed slice of the raw line when the tokens are
* not both inside one located literal (not currently reachable against this
* repo — see the header comment's per-file audit — but a fail-safe rather
* than a thrown error if a future line splits them).
*/
function extractFragment(line) {
for (let i = 0; i < line.length; i++) {
const ch = line[i];
let literal = null;
if (ch === '/') literal = readRegexLiteralAt(line, i);
else if (ch === "'" || ch === '"' || ch === '`') literal = readStringLiteralAt(line, i);
if (!literal) continue;
if (HEADING_QUANTIFIER_RE.test(literal.text)) return literal.text;
i = literal.end - 1; // resume scanning just past this literal
}
return line.trim().slice(0, MAX_REGEX_LITERAL_LEN);
}
/**
* Pure: find every unsanctioned milestone-window re-derivation in `text`.
* `relPath` is the repo-relative path, used both to report file:line and to
* apply the narrow, function-scoped exemptions above.
* Returns [{ line, found }].
*/
function findMilestoneWindowDrift(text, relPath) {
const out = [];
const lines = text.split('\n');
const exemptFunctions = FUNCTION_SCOPED_EXEMPTIONS.get(relPath) || null;
let currentFunction = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fnMatch = TOP_LEVEL_FUNCTION_RE.exec(line);
if (fnMatch) currentFunction = fnMatch[1];
if (!HEADING_QUANTIFIER_RE.test(line)) continue;
const isMilestoneWindowToken = PHASE_LOOKAHEAD_RE.test(line) || (VERSION_TOKEN_RE.test(line) && MARKER_EMOJI_RE.test(line));
if (!isMilestoneWindowToken) continue;
if (exemptFunctions && exemptFunctions.has(currentFunction)) continue;
out.push({ line: i + 1, found: extractFragment(line) });
}
return out;
}
/**
* Scan the authored source tree and return every unsanctioned re-derivation,
* each annotated with the repo-relative file path.
*/
function scanRepo(root) {
return scanTree({
root,
scanDirs: SCAN_DIRS,
scanExt: SCAN_EXT,
onFile(rel, text) {
// `rel` is already the REAL (canonical) path (scanTree resolves
// symlinks before calling onFile), so this comparison — and
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
// consistently regardless of which symlink reached the file.
if (rel === OWNER_FILE) return [];
return findMilestoneWindowDrift(text, rel).map((d) => ({ file: rel, ...d }));
},
});
}
function main() {
const root = path.join(__dirname, '..');
const violations = scanRepo(root);
if (violations.length === 0) {
process.stdout.write('ok milestone-window-drift: no unsanctioned milestone-window re-derivations outside roadmap-parser.cts\n');
return;
}
process.stderr.write('milestone-window-drift: independent re-derivation(s) of milestone-window bounding found.\n');
process.stderr.write('Use src/roadmap-parser.cjs `computeMilestoneSectionEnd` / `locateMilestoneHeadings` /\n');
process.stderr.write('`isMilestoneBoundedInRoadmap` instead of re-deriving the milestone heading/boundary regex:\n');
for (const d of violations) {
// `d.file` is exactly as attacker-controlled as `d.found`: a repo can
// legally track a filename containing control bytes / bidi overrides,
// and it is a fork-PR-authored value reaching a CI log the same way the
// matched literal does — sanitize it at the same reporting boundary.
process.stderr.write(` ${sanitizeForReport(d.file)}:${d.line} ${sanitizeForReport(d.found)}\n`);
}
process.exitCode = 1;
}
if (require.main === module) main();
module.exports = {
findMilestoneWindowDrift,
scanRepo,
HEADING_QUANTIFIER_RE,
PHASE_LOOKAHEAD_RE,
VERSION_TOKEN_RE,
MARKER_EMOJI_RE,
OWNER_FILE,
FUNCTION_SCOPED_EXEMPTIONS,
readStringLiteralAt,
extractFragment,
};

View File

@@ -47,18 +47,14 @@
* across two DIFFERENT lines with no single line carrying both, is not
* caught by this narrow shape. That is left to code review, not this regex.
*
* `isInsideRoot`'s root-confinement check (used by the symlink-following
* `walk`, below) is an EXACT string comparison, deliberately not
* case-normalized. On a case-insensitive filesystem (macOS default; not CI,
* which is ubuntu) a symlink whose target is a case-VARIANT of an in-root
* path — a path the OS itself would still resolve to the same file — is
* REJECTED by this exact comparison and silently left unscanned. This is a
* fail-CLOSED miss (a re-derivation goes unreported), never an escape (never
* a wrongly-admitted outside-root read), so it is left as-is: making the
* comparison case-insensitive would WEAKEN confinement (a resolved path
* merely case-differing from a sibling-of-root name, `/repo-Evil` vs
* `/repo-evil`, could then be wrongly admitted) to fix a gap that only ever
* under-reports on a platform this guard is not gated on.
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
* machinery below is SHARED with `scripts/lint-milestone-window-drift.cjs`
* (#3184) via `scripts/lib/drift-scan.cjs` — see that module for the
* `isInsideRoot` case-sensitivity note, the `walk` symlink-confinement
* rationale, and the `readRegexLiteralAt` tokenizer's ReDoS-avoidance
* rationale. It is deliberately NOT duplicated here a second time (ADR-3180
* Decision 4's own "Rejected" list: "let the new drift guard copy Phase 1's
* tree-walk / root-confinement / sanitizer").
*
* A regex literal longer than MAX_REGEX_LITERAL_LEN (400) characters is not
* read, and is therefore not caught. That bound is what keeps the scan
@@ -74,8 +70,9 @@
* survive, and the reason the detector is now a tokenizer.
*/
const fs = require('node:fs');
const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { readRegexLiteralAt, MAX_REGEX_LITERAL_LEN, isInsideRoot, sanitizeForReport, scanTree } = driftScan;
// A `.filter(` call on the line — the shape every current re-derivation uses
// to turn a directory listing into a plan-or-summary subset. Kept as its own
@@ -96,15 +93,6 @@ const FILENAME_TEST_RE = /\.(?:filter|test|match|exec|endsWith|startsWith|includ
// closing quote must match).
const PLAN_SUMMARY_LITERAL_RE = /(['"])-?(?:PLAN|SUMMARY)\.md\1/;
// Longest regex literal this scanner will consider, in characters. Real
// plan/summary filename filters are far shorter; the bound is what keeps the
// scan linear. The tokenizer restarts at every `/` on the line (so that a
// literal preceded by a stray unpaired `/` is still found, matching the
// previous regex's "find anywhere" behaviour), which without a per-literal
// bound would be quadratic on a pathological line. With it the whole-line
// cost is O(n * MAX_REGEX_LITERAL_LEN) with no backtracking at all.
const MAX_REGEX_LITERAL_LEN = 400;
// The two tokens that, appearing together INSIDE one regex literal, make it a
// plan/summary filename filter. `\.md` is matched as literal source text, not
// as a pattern, so there is nothing here to backtrack.
@@ -115,17 +103,6 @@ const ESCAPED_MD_TOKEN = '\\.md';
const SCAN_DIRS = ['src'];
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
// Directory names this scanner never descends into or reports out of —
// `.git` (repo internals, e.g. a persisted CI token in `.git/config`),
// `node_modules` (thousands of third-party files, none of them authored
// source), `dist` (build output). Named once and used at BOTH skip sites
// below: the cheap `entry.name` fast path in `walk`, and the resolved-path
// component check in `isUnderSkippedDir` — a symlink whose OWN name is not
// in this set but whose target resolves through a directory that IS (e.g.
// `src/g -> ../.git`, `src/nm -> ../node_modules`) must still be skipped, or
// the name-only check is a trivial bypass.
const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', '.git']);
// The canonical owner defines the grammar; it is exempt by construction.
const OWNER_FILE = path.join('src', 'plan-scan.cts');
@@ -197,50 +174,6 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
// FUNCTION_SCOPED_EXEMPTIONS entry above to take effect.
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
/**
* Read the JS regex literal starting at `line[start]` (which must be `/`).
* Returns `{ text, end }` — `text` includes the delimiters and any trailing
* flags, `end` is the index one past the literal — or null if no literal
* closes within MAX_REGEX_LITERAL_LEN characters.
*
* Single left-to-right pass, no backtracking. It models the two constructs a
* backtracking pattern gets wrong, which is why this is a tokenizer and not a
* regex:
* - `\x` escapes consume BOTH characters, so an escaped `\/` never
* terminates the literal;
* - inside a `[...]` character class a bare `/` does NOT terminate, so
* `/PLAN[\\/].*\.md$/` is one literal rather than two fragments. The
* previous regex silently MISSED every re-derivation using a
* cross-platform path-separator class for exactly this reason.
*/
function readRegexLiteralAt(line, start) {
if (line[start] !== '/') return null;
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
let inClass = false;
for (let i = start + 1; i < limit; i++) {
const ch = line[i];
if (ch === '\\') {
i++; // escape consumes the next character, whatever it is
continue;
}
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines
if (ch === '[') {
inClass = true;
} else if (ch === ']') {
inClass = false;
} else if (ch === '/' && !inClass) {
// Trailing flags are bounded by the SAME `limit` as the literal body
// itself (not `line.length`) — a literal followed by an unbounded run
// of lowercase letters must not make `text` grow past
// MAX_REGEX_LITERAL_LEN either.
let end = i + 1;
while (end < limit && line[end] >= 'a' && line[end] <= 'z') end++;
return { text: line.slice(start, end), end };
}
}
return null;
}
/**
* The regex literal on `line` that mentions PLAN or SUMMARY together with an
* escaped `.md` suffix — e.g. `/-PLAN\.md$/`, `/^PLAN-\d+.*\.md$/i`,
@@ -265,113 +198,6 @@ function findRegexLiteralMdMatch(line) {
return null;
}
// Symlinks report `isDirectory()`/`isFile()` as false on the Dirent from
// `readdirSync`, so a symlinked `src/*.cts` (or a symlinked directory
// containing one) was previously invisible to this scanner — an evasion of a
// guard whose stated design principle (ADR-3180 Decision 4a) is whole-repo
// discovery with no allowlist. Resolve each entry with `fs.statSync` (which
// follows symlinks) to classify it, skipping broken links. `ctx.visitedRealDirs`
// guards against a symlink cycle sending `walk` into infinite recursion.
//
// Every sibling drift guard in `scripts/` (`lint-phase-id-drift.cjs`,
// `lint-package-identity-drift.cjs`, `lint-portable-timeout.cjs`,
// `lint-test-file-count.cjs`, `lint-allow-test-rule-refs.cjs`) uses the
// `Dirent` classification straight off `readdirSync` and does NOT follow
// symlinks at all. This guard follows them so a symlinked `src/*.cts` cannot
// evade ADR-3180 Decision 4a's whole-repo discovery — root confinement
// (`isInsideRoot` below) is the price of doing so: without it, a symlink
// planted anywhere under `src/` could walk this scanner out to read and
// report arbitrary files elsewhere on disk.
//
// DIRECTORY vs FILE symlinks are confined to two DIFFERENT roots, tracked as
// `ctx.scanDirRoot` (the realpath of the current top-level SCAN_DIRS entry,
// e.g. `<realRoot>/src`) vs `ctx.realRoot` (the whole repo):
// - a DIRECTORY symlink is descended ONLY if its resolved realpath is
// inside `ctx.scanDirRoot` — NOT merely inside `ctx.realRoot`. Without
// this, `src/up -> ..` (or `-> <realRoot>`) resolves inside the repo
// root and `walk` descends the ENTIRE repo, reporting violations under
// paths like `tests/not-src.cts` or `docs/other.cts` — files the header
// above says the scan is scoped OUT of (`SCAN_DIRS`). This is a
// deliberate, fail-CLOSED trade-off: a directory symlink pointing
// elsewhere INSIDE the repo (but outside the scan directory) is simply
// not followed. The alternative — descending it — is exactly the
// whole-repo sweep this rule exists to prevent, and a fork PR could use
// that sweep to redden `lint:ci` on files this guard was never meant to
// read. The narrower rule is worth more than the missed edge case.
// - a FILE symlink is still scanned if its resolved realpath is inside
// `ctx.realRoot` (the whole repo, not just the scan directory) — this is
// what keeps `src/alias.cts -> vendor/real.cts` covered (test (f)): an
// aliased file genuinely is part of the compiled surface even when its
// real target lives outside `src/`, and it is still reported under its
// canonical (real) path.
//
// A resolved path is inside a root only if it IS that root or begins with
// root + separator — a plain `startsWith(root)` would also accept a sibling
// directory whose name merely starts with the root's name (`/repo-evil`).
function isInsideRoot(realPath, realRoot) {
return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
}
// True when `realPath` (already confirmed inside `realRoot` by `isInsideRoot`)
// resolves THROUGH a skip-list directory anywhere along its path relative to
// the root — not just when `realPath` itself IS one. This is what closes the
// symlink bypass the `entry.name` fast path alone cannot: `walk` tests
// `entry.name` (the symlink's OWN name in its parent directory), but a
// symlink named something innocuous can still RESOLVE into `.git` /
// `node_modules` / `dist` (`src/g -> ../.git`, `src/leak.cts ->
// ../.git/config`, `src/nm -> ../node_modules`) — `isInsideRoot` alone admits
// all three, because every one of those real paths is still under the root.
function isUnderSkippedDir(realPath, realRoot) {
const rel = path.relative(realRoot, realPath);
return rel.split(path.sep).some((segment) => SKIP_DIR_NAMES.has(segment));
}
function walk(dir, acc, ctx) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (SKIP_DIR_NAMES.has(entry.name)) continue; // cheap fast path
let stat;
try {
stat = entry.isSymbolicLink() ? fs.statSync(full) : entry;
} catch {
continue; // broken symlink target
}
let realPath;
try {
realPath = fs.realpathSync(full);
} catch {
continue; // broken symlink target (race, or a link stat() followed but realpath cannot)
}
if (stat.isDirectory()) {
// Directories (symlinked or real) are confined to the CURRENT scan
// directory root, not merely the repo root — see the comment above
// `isInsideRoot` for why (`src/up -> ..` whole-repo sweep).
if (!isInsideRoot(realPath, ctx.scanDirRoot)) continue;
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
if (ctx.visitedRealDirs.has(realPath)) continue; // symlink cycle guard
ctx.visitedRealDirs.add(realPath);
walk(full, acc, ctx);
} else if (stat.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
// Files are confined to the whole repo root — a symlinked FILE whose
// real target lives outside the scan directory but inside the repo
// (e.g. `src/alias.cts -> vendor/real.cts`) is still part of the
// compiled surface and must be scanned.
if (!isInsideRoot(realPath, ctx.realRoot)) continue;
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
if (ctx.visitedRealFiles.has(realPath)) continue; // two symlinks, same real file
ctx.visitedRealFiles.add(realPath);
acc.push(realPath);
}
}
return acc;
}
/**
* Pure: find every unsanctioned plan/summary-filter re-derivation in `text`.
* `relPath` is the repo-relative path, used both to report file:line and to
@@ -405,62 +231,19 @@ function findPlanCountDrift(text, relPath) {
* each annotated with the repo-relative file path.
*/
function scanRepo(root) {
const violations = [];
let realRoot;
try {
realRoot = fs.realpathSync(root);
} catch {
return violations; // root itself does not exist / is unreadable
}
for (const dir of SCAN_DIRS) {
const scanDirPath = path.join(root, dir);
let scanDirRoot;
try {
scanDirRoot = fs.realpathSync(scanDirPath);
} catch {
continue; // scan directory itself does not exist / is unreadable
}
const ctx = { realRoot, scanDirRoot, visitedRealDirs: new Set(), visitedRealFiles: new Set() };
for (const file of walk(scanDirPath, [], ctx)) {
// `file` is already the REAL path (walk pushes realPath, not the
// symlink path), so `rel` is the file's single canonical location
// regardless of which symlink reached it — this is what makes
// FUNCTION_SCOPED_EXEMPTIONS/OWNER_FILE, which are keyed on the
// repo-relative path, match consistently.
const rel = path.relative(realRoot, file);
if (rel === OWNER_FILE) continue;
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch {
continue;
}
for (const d of findPlanCountDrift(text, rel)) {
violations.push({ file: rel, ...d });
}
}
}
return violations;
}
// Both a `found` fragment AND a reported file path are attacker-controlled
// source text on a fork PR (a repo can legally track a filename containing
// control bytes, so the path is exactly as attacker-controlled as the
// fragment — see the call sites in main() below), and both are written
// straight to a CI log. Replace C0/C1 control bytes (ANSI escapes included)
// with a visible \xNN, AND the non-Latin-1 formatting/bidi/line-separator
// codepoints below with \uNNNN, so a crafted literal or filename cannot
// rewrite the terminal rendering of the report or hide/reorder its own text:
// - U+200B-U+200F: zero-width space/joiners and directional marks
// - U+2028/U+2029: Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR (line
// breaks a `\n`-only log scan would not catch)
// - U+202A-U+202E: bidi embedding/override controls (RLO etc.)
// - U+2066-U+2069: bidi isolate controls
function sanitizeForReport(text) {
return text
// eslint-disable-next-line no-control-regex -- the control range IS the target
.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.replace(/[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
return scanTree({
root,
scanDirs: SCAN_DIRS,
scanExt: SCAN_EXT,
onFile(rel, text) {
// `rel` is already the REAL (canonical) path (scanTree resolves
// symlinks before calling onFile), so this comparison — and
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
// consistently regardless of which symlink reached the file.
if (rel === OWNER_FILE) return [];
return findPlanCountDrift(text, rel).map((d) => ({ file: rel, ...d }));
},
});
}
function main() {

View File

@@ -37,6 +37,7 @@
"milestone-helper.test.cjs",
"milestone-prefixed-convention.test.cjs",
"milestone-summary.test.cjs",
"milestone-window-single-owner.test.cjs",
"milestone.test.cjs"
],
"issue": "TBD"

View File

@@ -29,7 +29,15 @@ import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
const {
getMilestonePhaseFilter,
extractCurrentMilestone,
getMilestoneInfo,
sliceMilestoneWindow,
} = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsMod = require('./core-utils.cjs');
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
@@ -520,18 +528,40 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
const today = realClock.localToday();
const milestoneName = options.name || version;
// Ensure archive directory exists (skipped in dry-run — no mutations)
if (!options.dryRun) {
platformEnsureDir(archiveDir);
}
// Scope stats and accomplishments to only the phases belonging to the
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
// (same logic used by cmdPhasesList and other callers).
// #3184 review finding: this scope computation + refusal MUST run BEFORE
// `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
// no --force) must be a true no-op on disk, and creating the archive
// directory first left an empty directory behind even on refusal.
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
if (isDirInMilestone.missingExplicitVersion) {
error(`no phases found for milestone ${version} in ROADMAP.md`);
}
// #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
// milestone window (ROADMAP/REQUIREMENTS archived, phase directories
// MOVED). #3166 is specifically the TRUNCATED case: the milestone's
// heading IS found but its section closes before the phase region, and the
// phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
// silently archiving every phase directory on disk. UNREADABLE (no
// ROADMAP.md at all) and UNSCOPED (no section for this version) are
// pre-existing, legitimately-handled states — `missingExplicitVersion`
// above already errors where that matters, and a missing ROADMAP.md has
// its own documented graceful path — so only TRUNCATED is refused here.
// The read-path consumers keep the pass-all degrade for every scope
// (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
// silent wrong answer for another); this write path refuses on TRUNCATED
// alone, positioned before `platformEnsureDir` so a refusal stays a no-op
// on disk.
if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
error(
`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
`(the milestone heading was found but its section ends before reaching any phase ` +
`entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
`cannot be trusted for this destructive operation. Re-run with --force to override.`,
);
}
// Guard: prevent marking complete when ROADMAP still lists phases that have
// no directory on disk (disk_status: no_directory). This catches the case
@@ -587,7 +617,21 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
}
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
// #3184/#2946: scope the unstarted-phase guard to the same `version`
// window `getMilestonePhaseFilter` used above, NOT to
// extractCurrentMilestone's own STATE.md-derived window — those two
// can disagree (that disagreement is exactly what the WARNING above
// detects), and scoping this guard to the wrong window under-detects
// unstarted phases on the destructive completion path. Calls the same
// sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
// branch calls (a prior pass here re-composed locate+select+section-end
// locally, which review caught as a second, disagreeing derivation of
// the same window — ADR-3180 Decision 4(c)); falls back to
// extractCurrentMilestone's whole-document result only for the
// free-form (no versioned milestones anywhere) shape, where both
// windows converge to the same value regardless of which version drove
// the lookup.
const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? 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[] = [];
@@ -737,6 +781,14 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
return;
}
// Ensure archive directory exists. Deliberately placed AFTER the dry-run
// early return and every refusal/guard above (missingExplicitVersion, the
// scope refusal, the unstarted-phase guard) — #3184 review finding: this
// used to run before those checks, so a refused run still left an empty
// archive directory behind. Reaching this point means the run is
// committed to mutating.
platformEnsureDir(archiveDir);
// Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');

View File

@@ -36,8 +36,12 @@ import { platformReadSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputMod = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import { tokenizeHeadings, stripTaggedBlocks, withSection } from './markdown-sectionizer.cjs';
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
@@ -97,7 +101,194 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup.
* #3184 (epic #3180 Phase 2): the sole owner of "where does this milestone
* heading's section end". Lifted from `currentMilestoneRawRanges`'s prior
* inline copy — the only one of three byte-identical copies that carried a
* "keep in sync" comment (evidence the risk was known, not controlled).
* `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`, and
* `getMilestonePhaseFilter`'s versionOverride branch all call this instead of
* re-deriving it.
*/
function computeMilestoneSectionEnd(content: string, headingText: string, headingStart: number): number {
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = headingStart + headingText.length;
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
// the next stop boundary without re-implementing fence detection. T4 seam migration.
const headings = tokenizeHeadings(content);
for (const h of headings) {
if (h.offset <= headingStart) continue;
if (h.offset < afterHeading) continue;
if (h.level > level) continue;
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
return h.offset;
}
return content.length;
}
/**
* #3184: the sole milestone-heading locator. Boundary-matched on the version
* token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
* because a milestone STATE legitimately selects its own sub-milestone
* heading (`v8.0` matching `## v8.0-B …` — `0` is a word char, `-` is not, so
* `\b` matches) — that is deliberate, load-bearing behavior (#730). The
* stricter `(?![\w.-])` boundary answers a DIFFERENT question — "is exactly
* this milestone shipped" (`isMilestoneShippedInRoadmap`) / "which Phase
* Details section belongs to exactly this one's version token"
* (`detailsVersionBoundary`) — and applying it here breaks #730 sub-milestone
* selection. `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`,
* and `getMilestonePhaseFilter`'s versionOverride branch all consume this
* instead of re-deriving their own heading-location regex.
*/
function locateMilestoneHeadings(content: string, version: string): RegExpExecArray[] {
const escapedVersion = escapeRegex(version);
const pattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi',
);
const matches: RegExpExecArray[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(content)) !== null) {
matches.push(m);
}
return matches;
}
/**
* #3184: named predicate replacing the two `state.cts` re-derivations
* (`buildStateFrontmatter`, `syncStateFrontmatter`) that each hand-rolled the
* same "is this version bounded to a versioned ROADMAP heading" regex. A
* straight consolidation of the two identical `state.cts` regexes onto the
* shared `locateMilestoneHeadings` owner — no behavior change.
*/
function isMilestoneBoundedInRoadmap(content: string, version: string): boolean {
return locateMilestoneHeadings(content, version).length > 0;
}
/**
* #3184: does this ROADMAP carry ANY versioned milestone heading (`v1.2`-style
* token on a level 1-3 non-Phase heading), independent of any particular
* version. `extractCurrentMilestoneScoped` (free-form-vs-versioned row 3/4
* classification) and `getMilestonePhaseFilter` (the deprecation warning +
* the same row 3/4 classification for its versionOverride branch) each
* hand-rolled this identically — the guard does not catch intra-owner-file
* copies by construction, so this was found by review instead.
*/
function hasVersionedMilestones(content: string): boolean {
return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
}
/**
* #3184/#2828/#1761: does this ROADMAP use milestone SECTIONING at all — i.e.
* does it carry any non-Phase heading at level 2-3? Deliberately weaker than
* `hasVersionedMilestones`: this needs to distinguish a FLAT unmilestoned
* roadmap (Phase headings only, where a whole-document phase count is
* correct) from a MILESTONED-but-unbounded one (where that count conflates
* sibling milestones, #1761) — that distinction is load-bearing and must not
* be collapsed into the versioned-milestone check. Owned here so the
* milestone heading vocabulary has one home; routes `state.cts`'s
* `buildStateFrontmatter` #2828 guard instead of a third hand-rolled copy.
*/
function hasMilestoneSectioning(content: string): boolean {
return /^#{2,3}\s+(?!Phase\s+\S)/mi.test(content);
}
/**
* #3184: the sole "which heading is this milestone's" rule — locate the version's
* headings, prefer the first that is not marked CLOSED/SHIPPED, else fall back to the
* first match. Returns null when the version has no heading at all.
*
* Extracted because three sites had written this same two-line selection
* independently (sliceMilestoneWindow, extractCurrentMilestoneScoped,
* currentMilestoneRawRanges) — the composition-level divergence ADR-3180
* Decision 4(c) covers: calling the owner's primitives and re-assembling the
* result locally is indistinguishable from re-deriving it.
*/
function selectMilestoneHeading(content: string, version: string): RegExpExecArray | null {
const matches = locateMilestoneHeadings(content, version);
if (matches.length === 0) return null;
return matches.find((m) => !isClosedMilestoneHeading(m[1])) ?? matches[0];
}
/**
* #3184: the sole "give me this version's window" composition. Delegates
* heading selection to `selectMilestoneHeading` (the sole selection owner)
* and then to `computeMilestoneSectionEnd` for the slice. Returns null when
* the version has no heading at all, so callers can distinguish "no such
* milestone section" from "empty section".
*
* Review finding (post-merge of this phase's first pass): `getMilestonePhaseFilter`'s
* versionOverride branch and `cmdMilestoneComplete`'s unstarted-phase guard
* had each independently composed `locateMilestoneHeadings` +
* `computeMilestoneSectionEnd` into a window — the SAME derivation written
* twice, and they disagreed (one skipped CLOSED headings, the other did not)
* — exactly the composition-level divergence ADR-3180 Decision 4(c) warns
* about: calling the owner and then re-assembling the result locally is
* indistinguishable from re-deriving it. Both sites now call this instead.
*/
function sliceMilestoneWindow(content: string, version: string): string | null {
const selected = selectMilestoneHeading(content, version);
if (selected === null) return null;
return content.slice(selected.index, computeMilestoneSectionEnd(content, selected[0], selected.index));
}
/**
* #3184: counts RAW phase references — a `#{2,4} Phase <id>:` heading
* (fence-aware via `tokenizeHeadings`) or a `#2199` bullet entry — BEFORE any
* sentinel filter. Used for BOTH sides of `classifyMilestoneWindow`'s row-8
* comparison (does the window contain phase entries; does the document).
* Deliberately does NOT filter `999.x`/Phase 0 sentinels: the question here
* is "did the window reach the phase region", not "how many real phases
* exist" — a window containing only sentinel phases still reached the
* region and must read COMPLETE, not TRUNCATED.
*/
function hasPhaseEntries(markdown: string): boolean {
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
for (const h of tokenizeHeadings(markdown)) {
if (h.level < 2 || h.level > 4) continue;
if (phaseHeadingPattern.test(h.text)) return true;
}
// #3184 review finding: the bullet fallback must be fence-aware too, or a
// FENCED markdown EXAMPLE of the `- [ ] **Phase N — Name**` syntax (e.g. a
// doc showing the convention) counts as a real phase entry. Strip fences
// through the canonical seam before testing, matching tokenizeHeadings'
// fence-awareness above.
return BULLET_PHASE_LINE_PATTERN.test(stripFencedCode(markdown).text);
}
/**
* #3184: pure decision table (no I/O, no regex construction from caller
* data) implementing the design's Behavior table rows 1-8 (the remaining
* rows 9-17 reduce to one of these six through how the caller constructs its
* input, not additional branches here). Kernighan's Law fired during design:
* `getMilestonePhaseFilter` is already cyclomatic 36, so this discriminator
* is extracted as its own named, separately-testable function rather than
* inlined.
*/
function classifyMilestoneWindow(input: {
readable: boolean;
versionResolved: boolean;
hasVersionedMilestones: boolean;
headingFound: boolean;
windowHasPhaseEntries: boolean;
documentHasPhaseEntries: boolean;
}): Scope {
const { readable, versionResolved, hasVersionedMilestones, headingFound, windowHasPhaseEntries, documentHasPhaseEntries } = input;
return (
!readable ? SCOPE.UNREADABLE : // row 2
!versionResolved && !hasVersionedMilestones ? SCOPE.COMPLETE : // row 3: free-form legacy roadmap
!versionResolved && hasVersionedMilestones ? SCOPE.UNSCOPED : // row 4
versionResolved && !headingFound ? SCOPE.UNSCOPED : // row 5
headingFound && !windowHasPhaseEntries && documentHasPhaseEntries ? SCOPE.TRUNCATED : // row 8
SCOPE.COMPLETE // rows 6, 7
);
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup,
* carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
*
* @param content - ROADMAP.md content.
* @param cwd - Project working directory, used to read the companion STATE.md
@@ -106,9 +297,18 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean
* `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
* default) preserves the prior `planningDir(cwd)` resolution exactly,
* including its `GSD_WORKSTREAM` env fallback.
*
* #3184: `extractCurrentMilestone`'s CRITICAL blast radius (200+ affected
* symbols, 20 direct callers) means its signature and return type do not
* change. This is the real owner; `extractCurrentMilestone` becomes a
* one-line wrapper returning `.value` so every existing caller is untouched.
*/
function extractCurrentMilestone(content: string, cwd?: string, ws?: string | null): string {
if (!cwd) return stripShippedMilestones(content);
function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: string | null): { value: string; scope: Scope } {
if (!cwd) {
// Row 1: a deliberate unscoped read (no cwd supplied) is a real answer —
// the caller asked for no scoping, so whole-document is COMPLETE.
return { value: stripShippedMilestones(content), scope: SCOPE.COMPLETE };
}
let version: string | null = null;
try {
@@ -129,18 +329,33 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
}
}
if (!version) return stripShippedMilestones(content);
const versionResolved = version !== null;
// #3184: routed through the shared owner (was an inline copy — see the
// twin copy in `getMilestonePhaseFilter`, the intra-owner-file duplicate
// review caught since the drift guard exempts this file by construction).
const versionedMilestonesPresent = hasVersionedMilestones(content);
const escapedVersion = escapeRegex(version);
const sectionPattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi'
);
if (!version) {
const value = stripShippedMilestones(content);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: false,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries: hasPhaseEntries(value),
}),
};
}
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content));
const summaryPattern = new RegExp(
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
`<summary[^>]*>([^<]*${escapeRegex(version)}[^<]*)<\\/summary>`,
'i'
);
const headingMatches = [...content.matchAll(sectionPattern)];
const headingMatches = locateMilestoneHeadings(content, version);
if (headingMatches.length === 0) {
const summaryMatch = content.match(summaryPattern);
@@ -161,39 +376,46 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return preamble + content.slice(detailsOpenIdx, detailsEnd);
const value = preamble + content.slice(detailsOpenIdx, detailsEnd);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: true,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
}
return stripShippedMilestones(content);
const value = stripShippedMilestones(content);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: false,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
const allMatches = headingMatches;
const isClosed = isClosedMilestoneHeading;
const firstMatch = allMatches[0];
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
// #3184: selection collapses to the sole owner; `allMatches` is still needed
// below (offsets, detailsMatch search), so only the selection itself routes
// through `selectMilestoneHeading` rather than the whole block.
const selected = selectMilestoneHeading(content, version)!;
const sectionStart = selected.index;
const computeSectionEnd = (headingText: string, headingStart: number): number => {
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = headingStart + headingText.length;
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
// the next stop boundary without re-implementing fence detection. T4 seam migration.
const headings = tokenizeHeadings(content);
for (const h of headings) {
if (h.offset <= headingStart) continue;
if (h.offset < afterHeading) continue;
if (h.level > level) continue;
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
return h.offset;
}
return content.length;
};
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
const firstMilestoneMatch = content.match(anyMilestonePattern);
@@ -231,7 +453,7 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
const detailsStart = detailsMatch.index ?? 0;
detailsSection = content.slice(
detailsStart,
computeSectionEnd(detailsMatch[0], detailsStart),
computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart),
);
}
@@ -250,9 +472,31 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
.replace(currentSectionHasPhaseDetails ? /^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim : /$/, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return detailsSection
const value = detailsSection
? preamble + currentSection + '\n' + detailsSection
: preamble + currentSection;
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: true,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
/**
* #3184: thin wrapper preserving `extractCurrentMilestone`'s exact signature
* and return type — CRITICAL blast radius (20 direct callers), so the type
* stays `string`. `extractCurrentMilestoneScoped` is the real owner; callers
* that need to branch on scope opt in to it directly.
*/
function extractCurrentMilestone(content: string, cwd?: string, ws?: string | null): string {
return extractCurrentMilestoneScoped(content, cwd, ws).value;
}
/**
@@ -603,6 +847,13 @@ type MilestonePhaseFilter = ((dirName: string) => boolean) & {
* the current milestone's.
*/
versionSectionFound: boolean;
/**
* #3184 (ADR-3180 Decision 2): the same window-classification carried by
* `extractCurrentMilestoneScoped`. The filter's FUNCTION behavior is
* UNCHANGED by this field — pass-all still passes all; a destructive
* consumer (`cmdMilestoneComplete`) reads `scope` to refuse instead.
*/
scope: Scope;
};
/**
@@ -629,13 +880,23 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
let missingExplicitVersion = false;
let versionScoped = false;
let versionSectionFound = false;
let scope: Scope = SCOPE.UNREADABLE;
try {
const roadmapPath = path.join(planningDir(cwd, ws), 'ROADMAP.md');
const roadmapContent = platformReadSync(roadmapPath);
if (roadmapContent === null) throw new Error('missing');
let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws);
let roadmap = scopedResult.value;
// Default: the filter's window IS extractCurrentMilestoneScoped's own
// window (reused verbatim, not re-derived — ADR-3180 Decision 4c).
// Overwritten below when `versionOverride` scopes to a DIFFERENT window.
scope = scopedResult.scope;
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
// #3184: routed through the shared owner (was an inline copy — see the
// twin copy in `extractCurrentMilestoneScoped`, the intra-owner-file
// duplicate review caught since the drift guard exempts this file by
// construction).
const hasVersionedMilestonesGlobal = hasVersionedMilestones(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
console.warn(
@@ -646,51 +907,48 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
}
if (versionOverride) {
const escapedVersion = escapeRegex(versionOverride);
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
let sectionMatch = roadmapContent.match(sectionPattern);
// #3184: route the whole "locate headings -> pick the active one ->
// section-end" composition through the single owner (sliceMilestoneWindow)
// instead of assembling it here. This branch used to be a bare `.match()`
// — first hit, no closed-heading skip, no version-token boundary — and a
// review pass caught it independently re-composing the SAME primitives
// `cmdMilestoneComplete`'s guard composed, disagreeing on closed-heading
// skipping. Now both sites call one function. Boundary-matched
// (`(?![\w.-])`) and closed-heading-skipping is a declared Tier-2 change
// affecting every caller that passes `versionOverride`: `roadmap.analyze`
// / `milestone complete` (this module, `cmdMilestoneComplete` in
// milestone.cts), `inspectWorkstream` (workstream-inventory.cts:518,
// via `currentVersion`), and `buildStateFrontmatter` (state.cts:1700,
// via `storedMilestone`).
const sliced = sliceMilestoneWindow(roadmapContent, versionOverride);
if (!sectionMatch) {
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
const summaryHit = roadmapContent.match(summaryPat);
if (summaryHit) {
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
const detailsIdx = beforeSummary.lastIndexOf('<details');
if (detailsIdx !== -1) {
sectionMatch = null;
}
}
}
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent));
if (!sectionMatch) {
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
if (sliced !== null) {
versionScoped = true;
versionSectionFound = true;
roadmap = sliced;
} else {
const escapedVersion = escapeRegex(versionOverride);
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
if (hasVersionedMilestones && !versionInSummary) {
if (hasVersionedMilestonesGlobal && !versionInSummary) {
roadmap = '';
missingExplicitVersion = true;
}
} else {
versionScoped = true;
versionSectionFound = true;
const sectionStart = sectionMatch.index!;
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = sectionStart + sectionMatch[0].length;
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
// the next milestone-boundary heading. T4 seam migration.
const allHeadings = tokenizeHeadings(roadmapContent);
let sectionEnd = roadmapContent.length;
for (const h of allHeadings) {
if (h.offset < afterHeading) continue;
if (h.level > headingLevel) continue;
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
sectionEnd = h.offset;
break;
}
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
roadmap = currentSection;
// else: version appears only inside a `<summary>`, or there are no
// versioned milestones anywhere — `roadmap` keeps
// extractCurrentMilestoneScoped's own (STATE-scoped) result, matching
// the pre-existing summary-block / free-form fallback shape.
}
scope = classifyMilestoneWindow({
readable: true,
versionResolved: true,
hasVersionedMilestones: hasVersionedMilestonesGlobal,
headingFound: sliced !== null,
windowHasPhaseEntries: hasPhaseEntries(roadmap),
documentHasPhaseEntries,
});
}
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
@@ -706,10 +964,15 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
// so a bullet-house-style ROADMAP populates the milestone phase set instead of
// collapsing to a zero-count pass-all filter.
// #3184 review finding: this scan must be fence-aware like `hasPhaseEntries`
// above — otherwise a fenced markdown EXAMPLE of the bullet syntax inflates
// milestonePhaseNums / phaseCount. Strip fences through the canonical seam
// first.
{
let bm: RegExpExecArray | null;
const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
while ((bm = scanner.exec(roadmap)) !== null) {
const roadmapUnfenced = stripFencedCode(roadmap).text;
while ((bm = scanner.exec(roadmapUnfenced)) !== null) {
if (!/^999\b/.test(bm[1])) milestonePhaseNums.add(bm[1]);
}
}
@@ -719,7 +982,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
* any failure milestonePhaseNums stays empty, which below already
* degrades to the same pass-all filter this function returns when a
* ROADMAP genuinely has zero recognizable phase headings — a safe,
* non-corrupting (over-inclusive, never under-inclusive) degrade. */
* non-corrupting (over-inclusive, never under-inclusive) degrade.
* #3184: `scope` was set to SCOPE.UNREADABLE before the try (row 2) and
* is left as-is here — the read/parse fault IS the unreadable case. */
}
if (milestonePhaseNums.size === 0) {
@@ -731,6 +996,10 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// `versionScoped` is reset here — this is the only surviving evidence that
// the current milestone exists in the ROADMAP and simply has no phases yet.
passAll.versionSectionFound = versionSectionFound;
// #3184: the filter's FUNCTION behavior is unchanged — pass-all still
// passes all. `scope` is the decidable signal a destructive consumer
// reads to refuse instead (ADR-3180 Decision 3's two-tier policy).
passAll.scope = scope;
return passAll;
}
@@ -776,6 +1045,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
(isDirInMilestone as MilestonePhaseFilter).versionScoped = versionScoped;
(isDirInMilestone as MilestonePhaseFilter).versionSectionFound = versionSectionFound;
(isDirInMilestone as MilestonePhaseFilter).scope = scope;
return isDirInMilestone as MilestonePhaseFilter;
}
@@ -785,12 +1055,12 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
* writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
* same-numbered phase in a shipped milestone.
*
* Mirrors the region selection in `extractCurrentMilestone` (version detection →
* active heading → next milestone boundary → optional Phase Details section).
* Returns null when there is no versioned active milestone; callers then fall
* back to whole-content mutation (the prior behaviour).
*
* NOTE: keep the region logic here in sync with extractCurrentMilestone.
* Mirrors the region selection in `extractCurrentMilestoneScoped` (version
* detection → active heading → next milestone boundary → optional Phase
* Details section) — both consume the same `locateMilestoneHeadings` /
* `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
* keep in sync. Returns null when there is no versioned active milestone;
* callers then fall back to whole-content mutation (the prior behaviour).
*/
function currentMilestoneRawRanges(
content: string,
@@ -813,33 +1083,16 @@ function currentMilestoneRawRanges(
}
if (!version) return null;
const escapedVersion = escapeRegex(version);
const sectionPattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi',
);
const headingMatches = [...content.matchAll(sectionPattern)];
const headingMatches = locateMilestoneHeadings(content, version);
if (headingMatches.length === 0) return null;
const isClosed = isClosedMilestoneHeading;
const firstMatch = headingMatches[0];
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
// #3184: selection collapses to the sole owner; `headingMatches` is still
// needed below for the detailsMatch search over all headings.
const selected = selectMilestoneHeading(content, version)!;
const sectionStart = selected.index ?? 0;
const computeSectionEnd = (headingText: string, headingStart: number): number => {
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = headingStart + headingText.length;
for (const h of tokenizeHeadings(content)) {
if (h.offset <= headingStart) continue;
if (h.offset < afterHeading) continue;
if (h.level > level) continue;
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
return h.offset;
}
return content.length;
};
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
const selectedVersionToken = selected[1].match(
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
@@ -857,7 +1110,7 @@ function currentMilestoneRawRanges(
let details: { start: number; end: number } | null = null;
if (detailsMatch) {
const detailsStart = detailsMatch.index ?? 0;
details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
}
return { primary: { start: sectionStart, end: sectionEnd }, details };
@@ -866,11 +1119,23 @@ function currentMilestoneRawRanges(
export = {
stripShippedMilestones,
extractCurrentMilestone,
extractCurrentMilestoneScoped,
isMilestoneShippedInRoadmap,
isMilestoneBoundedInRoadmap,
replaceInCurrentMilestone,
getRoadmapPhaseInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
currentMilestoneRawRanges,
withPhaseSection,
computeMilestoneSectionEnd,
locateMilestoneHeadings,
selectMilestoneHeading,
classifyMilestoneWindow,
// #3184: the sole "give me this version's window" composition — see its
// own doc comment. milestone.cts's destructive-consumer guard consumes
// this instead of composing locate+select+section-end itself.
sliceMilestoneWindow,
hasVersionedMilestones,
hasMilestoneSectioning,
};

View File

@@ -20,7 +20,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
@@ -309,7 +309,10 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
@@ -486,6 +489,11 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
// #3184/#3165: distinguishes a genuinely empty milestone (`scope:
// "complete"`, `phase_count: 0`) from a window that could not be fully
// resolved (`"truncated"` / `"unscoped"` / `"unreadable"`) — those cases
// were previously output-identical.
scope,
};
output(result, raw, undefined);

View File

@@ -19,7 +19,7 @@ import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
@@ -1775,11 +1775,12 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
// downstream (mirrors the sync write-path guard).
let milestoneBounded = true;
if (milestone && roadmapRaw !== null) {
const versionedHeading = new RegExp(
`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`,
'mi',
);
milestoneBounded = versionedHeading.test(roadmapRaw);
// #3184: routed through the single owner (roadmap-parser.cjs)
// instead of a hand-rolled, unbounded-substring re-derivation —
// the prior inline regex had no boundary assertion after the
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
// defect, design row 17).
milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim());
}
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
// at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -1787,10 +1788,14 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
// On a flat roadmap the whole-doc count is correct (no sibling milestones to
// conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
// so fall back to phaseDirs.length.
const hasMilestoneSectioning = roadmapRaw !== null
&& /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
// #3184: routed through the single owner (roadmap-parser.cjs) —
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
// version-token requirement); see hasMilestoneSectioning's own
// doc comment for why that distinction is load-bearing.
const roadmapHasMilestoneSectioning = roadmapRaw !== null
&& hasMilestoneSectioning(roadmapRaw);
const safeToUseRoadmapCount = milestoneBounded
|| (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
|| (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
return {
totalPhases: safeToUseRoadmapCount
? Math.max(phaseDirs.length, roadmapPhaseCount)
@@ -2986,8 +2991,10 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
let milestoneBounded = true;
if (versionStr !== null && syncRoadmapRaw !== null) {
const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
milestoneBounded = versionedHeading.test(syncRoadmapRaw);
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
// a hand-rolled, unbounded-substring re-derivation — see the identical
// fix in buildStateFrontmatter above.
milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
}
let percent: number | null = null;
if (!milestoneBounded) {

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"next.md": "#3184 (epic #3180 Phase 2): Route 0 (resume_incomplete_phase) grows by one branch that reads the new `scope` field `roadmap analyze` emits. Before this, Route 0 treated a well-formed `{phases: []}` as a clean scan and fell through to routing as though the incomplete-phase invariant (#160) had been checked and passed — the silent disarm #3165 reports, where a milestone window truncated by a closed-milestone heading yields an empty phase list with exit 0 and no error. The added `elif [ \"$ROADMAP_SCOPE\" != \"complete\" ]` arm routes that case into the SAME warn-and-fall-through branch the existing `roadmap.analyze failed` arm already uses, so a non-answer is no longer indistinguishable from a genuinely empty scan. The growth is the new arm's three warning lines, its explanatory comment, the `ROADMAP_SCOPE` assignment, and one success-criteria bullet. Adding this to the workflow rather than only to the CLI is deliberate: a spec review of this phase found that emitting `scope` without a consumer left #3165's actual symptom reproducing, so the field would have been a diagnostic nobody reads."
}
}

View File

@@ -210,7 +210,17 @@ describe('#2658: end-to-end --trae install never emits the malformed path (accep
test('local install: no emitted .md/.js/.cjs file contains the malformed strings; the rules file is concrete', () => {
const { configDir, root } = runMinimalInstall({ runtime: 'trae', scope: 'local' });
try {
const files = walk(configDir).filter((f) => /\.(md|js|cjs)$/.test(f));
const files = walk(configDir)
.filter((f) => /\.(md|js|cjs)$/.test(f))
// gsd-core/CHANGELOG.md is excluded by exact relative path (not a blanket
// .md skip — the emitted agent/command/workflow markdown this gate exists
// to guard stays fully scanned). CHANGELOG.md legitimately QUOTES the
// malformed `.claude/.trae/rules` / `.trae/.trae/rules` strings while
// documenting the #2658 fix itself (#3006) — that historical-value
// citation is not a regression of the installer's actual output. Verified
// empirically: excluding only this one file drops the hit count to zero
// across all 620 other emitted files.
.filter((f) => f.split(path.sep).join('/').indexOf('gsd-core/CHANGELOG.md') === -1);
assert.ok(files.length > 0, 'expected at least one emitted .md/.js/.cjs file');
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');

View File

@@ -415,6 +415,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -485,6 +485,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-ns-context/SKILL.md",
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",

View File

@@ -483,5 +483,6 @@
"scripts/gen-capability-registry.cjs",
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs"
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs"
]

View File

@@ -413,6 +413,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -385,6 +385,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-ns-context/SKILL.md",
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",

View File

@@ -485,6 +485,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -492,5 +492,6 @@
"scripts/gen-capability-registry.cjs",
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs"
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs"
]

View File

@@ -384,6 +384,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -391,6 +391,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -414,6 +414,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd/DESCRIPTION.md",
"skills/gsd/gsd-ns-context/SKILL.md",
"skills/gsd/gsd-ns-context/skills/docs-update/SKILL.md",

View File

@@ -488,6 +488,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -414,6 +414,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -450,6 +450,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -488,6 +488,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-add-tests/SKILL.md",
"skills/gsd-ai-integration-phase/SKILL.md",
"skills/gsd-audit-fix/SKILL.md",

View File

@@ -381,5 +381,6 @@
"scripts/gen-capability-registry.cjs",
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs"
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs"
]

View File

@@ -414,6 +414,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-ns-context/SKILL.md",
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",

View File

@@ -382,6 +382,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-ns-context/SKILL.md",
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",

View File

@@ -384,5 +384,6 @@
"scripts/gen-capability-registry.cjs",
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs"
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs"
]

View File

@@ -453,6 +453,7 @@
"scripts/gen-loop-host-contract.cjs",
"scripts/lib/allowlist-ratchet.cjs",
"scripts/lib/cli-exit.cjs",
"scripts/lib/drift-scan.cjs",
"skills/gsd-ns-context/SKILL.md",
"skills/gsd-ns-context/skills/docs-update/SKILL.md",
"skills/gsd-ns-context/skills/extract-learnings/SKILL.md",

View File

@@ -49,6 +49,8 @@ const {
configureKiloPermissions,
selectRuntimesFromArgs,
normalizeNodePath,
GSD_CHANGESET_FILES,
GSD_SCRIPTS_LIB_FILES,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
@@ -11148,3 +11150,48 @@ describe('#3026: installer --help documents every accepted runtime flag', () =>
`--help must document every accepted runtime flag; missing: ${missing.join(', ')}`);
});
});
// ─── #3184: scripts/lib/ and scripts/changeset/ install/uninstall parity ────
//
// install() copies both source directories WHOLESALE (every file present —
// see bin/install.js's "and any future lib helpers" comment), but uninstall()
// removes files via an EXPLICIT hardcoded enumeration (GSD_SCRIPTS_LIB_FILES /
// GSD_CHANGESET_FILES). Nothing keeps the two in sync: a file added to either
// source directory ships to every install and then orphans on uninstall (it
// survives removal, keeps the target dir non-empty, and blocks its rmdir).
// This guard fails the moment the real directory outgrows the enumeration.
/**
* Pure comparison: every file actually present in a source dir must be named
* in the corresponding uninstall enumeration. Decoupled from fs so it can be
* exercised directly with doctored input (see the probe in the PR report).
*/
function findUnenumeratedFiles(actualFiles, enumeratedFiles) {
const enumerated = new Set(enumeratedFiles);
return actualFiles.filter(f => !enumerated.has(f));
}
describe('#3184: scripts/lib/ and scripts/changeset/ install/uninstall parity', () => {
const REPO_ROOT = path.join(__dirname, '..');
function realFilesIn(relativeDir) {
const dir = path.join(REPO_ROOT, relativeDir);
return fs.readdirSync(dir).filter(entry => fs.statSync(path.join(dir, entry)).isFile());
}
test('every file in scripts/lib/ is enumerated in GSD_SCRIPTS_LIB_FILES (bin/install.js)', () => {
const unenumerated = findUnenumeratedFiles(realFilesIn(path.join('scripts', 'lib')), GSD_SCRIPTS_LIB_FILES);
assert.deepEqual(unenumerated, [],
`scripts/lib/${unenumerated.join(', scripts/lib/')} exist on disk but are missing from ` +
`GSD_SCRIPTS_LIB_FILES in bin/install.js — add ${unenumerated.length === 1 ? 'it' : 'them'} to that ` +
'array so uninstall() removes it (otherwise it ships to every install and orphans on uninstall).');
});
test('every file in scripts/changeset/ is enumerated in GSD_CHANGESET_FILES (bin/install.js)', () => {
const unenumerated = findUnenumeratedFiles(realFilesIn(path.join('scripts', 'changeset')), GSD_CHANGESET_FILES);
assert.deepEqual(unenumerated, [],
`scripts/changeset/${unenumerated.join(', scripts/changeset/')} exist on disk but are missing from ` +
`GSD_CHANGESET_FILES in bin/install.js — add ${unenumerated.length === 1 ? 'it' : 'them'} to that ` +
'array so uninstall() removes it (otherwise it ships to every install and orphans on uninstall).');
});
});

File diff suppressed because it is too large Load Diff