refactor(#3180): ADR-3180 behavior contract + cross-surface drift guardrails (#3223)

* refactor(#3180): one owner for completion ratio, a prompt-layer drift guard, and a written behavior contract

The 2026-08-08 coverage audit on #3180 found the epic's copy counts were a
lower bound for the third consecutive time, and that two derivation families
had never been named at all.

ADR-3180 gains Decision 7 — a normative behavior contract that says what the
right answer IS for each derivation, not merely who owns it. A reviewer with
no written rule can only ask "does this look like the others", which is how a
fifth copy passes review. Decision 4 gains (d) scan surface is every authored
surface and an owner FILE is never exempt, only its named functions; and (e)
a surface that cannot be consolidated today ships ratcheted, never unguarded.

Completion ratio: `clampPercent` sat exported and unused beside six hand-inlined
copies of its own body across five modules. All six now route through it;
`clampPercentFromFraction` is added for the one caller that already held a
fraction. Every migration is behaviour-identical — clampPercent's first line IS
the `total > 0 ? … : 0` ternary each copy carried. Guarded by
lint-completion-ratio-drift.cjs, which reports zero re-derivations with no
file-level exemption.

Prompt layer: workflow markdown re-derives live-plan counting in raw shell
(#1762), invisible to every `src/`-scoped guard. lint-planning-prompt-drift.cjs
scans it with a shrink-only baseline of the 7 sites that exist today — new
sites fail, and a baseline entry that stops firing fails too, so an
acknowledgment can never outlive the thing it describes.

lint-milestone-window-drift.cjs stops exempting its owner file wholesale; only
the four named canonical functions are exempt now. The blanket exemption was
pointed at the one file most likely to grow the next copy, and it had.

Refs #3180

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3180): link Phases 6-8 sub-issues (#3216, #3217, #3218) from ADR-3180

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3180): address orthogonal review — consumer-output identity tests, count-keyed ratchet, property coverage

Five findings from the two orthogonal review passes, all fixed.

Decision 4(c) breach: the completion-ratio identity test asserted at the
OWNER, which is exactly the bypass that decision exists to close — a consumer
can call clampPercent and then post-process locally, leaving both the lint and
an owner-level test green. It now drives `roadmap analyze`, `query progress`
and `stats` and asserts on their own output, over a fixture containing a
`status: superseded` plan so a consumer that re-counted raw files would report
60 where the owner reports 75.

Decision 4(e) breach: ratchet entries named the epic (#3180) rather than the
issue that removes them. They name Phase 8 (#3218) now.

The ratchet keyed on (file, text) alone, so plan-phase.md's two byte-identical
sites were one indistinguishable key and migrating either would have left the
guard green with the other alive. Entries carry an occurrence count; fewer than
acknowledged fails as a partial migration, more fails as a new copy.

Adds the missing MAX_REGEX_LITERAL_LEN boundary coverage the sibling guard's
test already had, and the fast-check property tests CONTRIBUTING requires for
clamp/budget-limit functions.

Refs #3180

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: stop wrapping a nested double-spawn in a 15s wall-clock budget (bug #641 probes)

`tests/ci-test-scope.test.cjs`'s `bug #641` block spawned `run-tests.cjs`
under PROBE_TIMEOUT_MS=15000; that child then spawned a nested `node --test`.
A fixed wall-clock budget around a double spawn, running inside a container
that is concurrently executing the full ~31k-test suite, fails by construction
under load.

Confirmed against three full matrix runs. Every failure was shaped
`null !== 0` — the child was KILLED, never an assertion about the thing under
test. One captured probe had already printed the correct resolution
(`suite="all" files=2: a.test.cjs b.test.cjs`) and was killed anyway. It
reproduces on `next` alone: 5 failures on linux-node22, 0 on linux-node24. The
victim subset varies by run and by lane.

What these tests are actually about is suite-token RESOLUTION — `unit` as a
bare token in --files/--files-from. Executing the seeded trivial files is
incidental and is the entire timeout surface, so the assertions move
in-process against the same functions `main()` calls, in the same order.
`parseArgs`, `selectExplicitFiles`, `selectFiles` and `walkTestFiles` are
exported for that; no behavior, signature or logic changed.

No coverage lost: `tests/run-tests-harness.test.cjs` already spawns the
harness for real and asserts exit codes end to end, on a 120s budget.

Pre-existing on `next`, fixed here rather than deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: delete the three elapsed-time assertions

CLAUDE.md forbids asserting on wall-clock time. Three assertions did, and all
three are load-sensitive: on a saturated bench each can fail while the code
under test is correct. In every case the load-bearing assertion sits on the
line above and the timing line adds no discrimination.

run-with-timeout: the stated worry — "was this 124 the cap firing or the 30s
harness backstop?" — is already answered by the assertion above it. A backstop
kills by signal, which surfaces as status null, never 124. Observed directly
this session: three matrix runs produced exactly that null shape from killed
children.

normalize-test-command and context-predicates: both bounded a ReDoS check.
A threshold only ever separates "fast" from "slightly slow", which is bench
load, not correctness — catastrophic backtracking on 800 KB of input does not
take 251ms, it does not finish at all. A real regression therefore shows up as
the suite being killed on that test, which is louder and more reliable than a
number. The structural assertions (returned unchanged; cleanly rejected) are
what actually carry those tests, and they stay.

The sweep now reports zero elapsed-time assertions in tests/. The remaining
Date.now() uses are unique-path suffixes, barrier deadlines, fixture
timestamps and fake mtimes — none of them assertions.

Pre-existing on `next`, fixed here rather than deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3180): backfill changeset PR number (#3223)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3180): key the prompt-drift ratchet on POSIX paths so it works on Windows

The baseline keys on (file, trimmed text). `file` came from scanTree's
`path.relative()`, which uses NATIVE separators, while the committed baseline
stores POSIX. On Windows every violation was therefore unmatched — reported as
FRESH — and every baseline entry matched nothing — reported as STALE. The guard
failed 100% of the time there, on both CI shards:

  ✖ scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale
    + { file: 'gsd-core\\workflows\\execute-plan.md', ... }

The remote runner this repo gates on is Linux-only and cannot see this class at
all; the GitHub Actions Windows lane is what caught it.

Normalization is unconditional — never gated on process.platform. A
platform-conditional normalizer makes the POSIX path the special case and
leaves the Windows branch unexercised on every other OS, which is the same
blind spot in a different place. It is applied at one seam inside
findPromptDrift, which builds `file` on every returned violation, so the
baseline key, the --update writer, the stderr report and the tests all consume
one normalized value.

The regression tests drive a Windows-shaped relPath directly and run on every
OS rather than skipping off-Windows — a test that only runs on the platform
where the bug lives is why this escaped. They include a sanity check that
un-normalized input does NOT match, so the assertion cannot pass vacuously.

Audited the three sibling guards: none keys against a committed cross-platform
baseline, and their exemption keys are path.join-built, so producer and
consumer share the native convention. Left correct code alone rather than
making them look alike. scripts/lib/drift-scan.cjs is untouched — normalizing
there would break those three on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-08 16:05:17 -04:00
committed by GitHub
parent 636ec92107
commit b9f51836e6
22 changed files with 2023 additions and 101 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3223
---
**Progress percentages now come from one owner** — every `.planning/` completion percentage the CLI reports is computed by a single shared function instead of six hand-inlined copies, so a rounding or ceiling fix can no longer land on one command and silently miss the others. Reported values are unchanged. (#3180)

View File

@@ -1,6 +1,6 @@
# ADR-3180: Planning Semantic Model — Single Owner per Derivation # ADR-3180: Planning Semantic Model — Single Owner per Derivation
- **Status:** Accepted (Phase 0 — ADR only; locks the contract Phases 1–5 execute against. No production code lands in this PR.) - **Status:** Accepted. Phase 0 shipped this file alone; Amendment 4 (2026-08-08) adds Decision 7 — the normative behavior contract — and lands the guards and the completion-ratio consolidation described there.
- **Date:** 2026-08-07 - **Date:** 2026-08-07
- **Issue:** [#3180](https://github.com/open-gsd/gsd-core/issues/3180) is the **scope authority** (`epic` + `approved-enhancement` + `type: chore`), which is why this ADR carries its number. [#3182](https://github.com/open-gsd/gsd-core/issues/3182) is the Phase-0 tracking sub-issue this PR closes — the epic stays open until the final phase merges. This follows [ADR-3128](3128-adaptive-runtime-evidence.md), whose filename likewise tracks its scope-authority issue while its PR referenced a separate docs sub-issue. - **Issue:** [#3180](https://github.com/open-gsd/gsd-core/issues/3180) is the **scope authority** (`epic` + `approved-enhancement` + `type: chore`), which is why this ADR carries its number. [#3182](https://github.com/open-gsd/gsd-core/issues/3182) is the Phase-0 tracking sub-issue this PR closes — the epic stays open until the final phase merges. This follows [ADR-3128](3128-adaptive-runtime-evidence.md), whose filename likewise tracks its scope-authority issue while its PR referenced a separate docs sub-issue.
- **Supersedes:** nothing - **Supersedes:** nothing
@@ -176,6 +176,21 @@ Therefore each derivation's identity test compares **each consumer's observable
*Rejected:* a lint that asserts all sites match a golden regex — it enforces textual sameness, not single ownership, and cannot see semantic divergence (this is ADR-2121 Decision 1's rejected option (C), and it applies unchanged here). *Rejected:* a lint that asserts all sites match a golden regex — it enforces textual sameness, not single ownership, and cannot see semantic divergence (this is ADR-2121 Decision 1's rejected option (C), and it applies unchanged here).
**(d) A guard's scan surface is every AUTHORED surface that can express the derivation — not `src/`.** Constraint (a) said "whole-repo scan, never an allowlist of known files", and both guards built under it read that as *the whole `src/` tree*. That is itself an allowlist, one directory wide. #1762's second reproduction traced a wrong `30 plans, 24 summaries` figure to a raw `ls -1 … *-PLAN.md | wc -l` snippet inside `gsd-core/workflows/progress.md` — a live-plan re-derivation `lint-plan-count-drift.cjs` reported clean because it was not looking there.
A derivation is re-derived wherever it is *expressed*, and this product expresses these four in two languages: TypeScript under `src/`, and shell embedded in the workflow/command markdown that ships to every runtime. A guard covering one of the two measures half the surface and reports zero. Each derivation's guard therefore declares its scan surface explicitly, and any derivation reachable from the prompt layer is covered there too — `scripts/lint-planning-prompt-drift.cjs` scans `gsd-core/workflows`, `commands`, `agents` and `skills`.
The same constraint applies inward: **a guard's owner FILE is not exempt, only its named canonical FUNCTIONS are.** A whole-file exemption on the owner is constraint (a)'s forbidden allowlist aimed at the one file most likely to grow the next copy, and it did — see Amendment 4.
**(e) Where a surface cannot be consolidated in the same change, the guard ships RATCHETED — never absent.** A derivation expressed in the prompt layer cannot be routed onto its `.cts` owner by an import; it needs a CLI surface to call, which is a phase of its own. The guard still lands, carrying a baseline of the sites that exist at that moment, and follows `scripts/qa-smell-ratchet.cjs`'s invariants exactly:
- a recorded site never fails — it is acknowledged, in writing, with the issue that owns its removal;
- an unrecorded site fails — nobody has looked at it;
- a recorded site that no longer fires **also** fails, so the baseline can only shrink and an acknowledgment can never outlive the thing it describes;
- entries are keyed on `(file, trimmed source text)` plus an occurrence **count**, never on a line number, which churns on every unrelated edit to the same file. The count is what makes a *partial* migration visible: two byte-identical sites in one file would otherwise be one indistinguishable key, so migrating one of them would leave the ratchet green while the other survived. Fewer occurrences than acknowledged fails as a partial migration; more fails as a new copy planted beside an acknowledged one.
*Rejected:* land the guard later, together with the migration. That is the "found it, wrote it down, moved on" posture this epic exists to remove — between the finding and the migration the surface is known-broken *and* unwatched, which is strictly worse than unknown. *Rejected:* a bare `eslint-disable`-style suppression. A suppressed guard and a green guard are indistinguishable at a glance; a ratchet reports its own remaining debt on every run.
### 5. Migration order — live-plan counting ships BEFORE milestone windowing ### 5. Migration order — live-plan counting ships BEFORE milestone windowing
**Locked order:** Phase 1 (live-plan counting) → Phase 2 (milestone windowing) → Phase 3 (enumeration) → Phase 4 (completion) → Phase 5 (state field extraction). **Locked order:** Phase 1 (live-plan counting) → Phase 2 (milestone windowing) → Phase 3 (enumeration) → Phase 4 (completion) → Phase 5 (state field extraction).
@@ -206,6 +221,109 @@ Recording the subsumption explicitly because both silences are failures: a phase
**Scope note on Phase 5.** State field extraction is *not* one of #3180's seven "Done when" items — the epic describes it in evidence as "a fifth instance of the same shape" and lists #3162 among the out-of-scope child defects, while its Goal ("one canonical owner per semantic derivation") covers it. That inconsistency was surfaced during planning and resolved by maintainer decision to include it. #3180's Done-when list should be amended to match, or Phase 5 reads as unclaimed scope. **Scope note on Phase 5.** State field extraction is *not* one of #3180's seven "Done when" items — the epic describes it in evidence as "a fifth instance of the same shape" and lists #3162 among the out-of-scope child defects, while its Goal ("one canonical owner per semantic derivation") covers it. That inconsistency was surfaced during planning and resolved by maintainer decision to include it. #3180's Done-when list should be amended to match, or Phase 5 reads as unclaimed scope.
### 7. The behavior contract — this section is the SOURCE OF TRUTH
Decisions 1–6 answer *who owns* each derivation. They do not say *what the right answer is*, and that omission is why the 2026-08-08 coverage audit could find six copies the epic had never named: a reviewer with no written rule to check a call site against can only ask "does this look like the others", which is how a fifth copy passes review.
This section is that written rule. It is **normative**, and it is what the guards and identity tests of Decision 4 test *against*:
- **Where this section and the code disagree, the code is the defect** — not this section, and not a caller's local expectation.
- A behavior not stated here is **not decided**. It is recorded below as an open question with a forcing function, never resolved silently inside an implementation PR.
- Amending a rule here is an amendment to this ADR (Amendments section), not a code change with a comment.
- Each rule carries a **status**: *Enforced* (owner exists, guard green) or *Required — Phase N* (contract locked, migration outstanding). A *Required* rule is as binding as an *Enforced* one; the only difference is whether the tree satisfies it yet.
#### 7.1 Milestone windowing — *Enforced (Phase 2)*
**Question.** Which byte range of `ROADMAP.md` belongs to milestone `M`?
**Owner.** `src/roadmap-parser.cts` — `locateMilestoneHeadings`, `computeMilestoneSectionEnd`, `isMilestoneBoundedInRoadmap`, and the composition `sliceMilestoneWindow`.
**Rule.** The window opens at the heading `locateMilestoneHeadings` selects for `M` and closes where `computeMilestoneSectionEnd` says. A `### Phase N: …` heading never opens or closes a milestone window. The version token's boundary is `\b`, **not** `(?![\w.-])` — a milestone STATE of `v8.0` legitimately selects `## v8.0-B …` over a closed `v8.0-A` sibling (#730; Amendment 2 tried the stricter boundary and reverted it). A free-form legacy ROADMAP carrying no versioned milestone heading is `COMPLETE`, not `UNSCOPED`: whole-document genuinely *is* the milestone there. **A composition of these primitives is itself an owner** — assembling `locate → pick → computeEnd → slice` at a call site is a re-derivation even though every step calls the owner (Amendment 2).
**Failure signal.** `ScopedResult.scope` per Decision 2.
**Guard.** `scripts/lint-milestone-window-drift.cjs`.
#### 7.2 Milestone identity — *Required — Phase 6*
**Question.** Which milestone is current, and what is it called?
**Owner (to be).** `getMilestoneInfo` binds to `locateMilestoneHeadings` and deletes its own heading regexes. It is a **sixth derivation family** — the coverage audit's gap 2 — that no phase of the original decomposition touches.
**Rule.**
1. `STATE.md`'s `milestone:` field selects the version when present; the ROADMAP heuristics are the fallback, not the primary.
2. The heading is located by the canonical locator of §7.1, which already excludes phase headings. A `### Phase N: Close v3.3 gaps` heading is **never** the milestone heading (#3197 — reproduced live, writing a wrong `milestone:` to disk).
3. The **name** is the heading text following the version token with a leading delimiter (`—`, `–`, `:`, `-`) stripped. `(` is an ordinary name character: the name is **not** truncated at a parenthetical (#3171).
4. A failure returns a `scope` other than `COMPLETE`. It does **not** return `{version: 'v1.0', name: 'milestone'}` presented as an answer — that default is output-identical to a successful read of a genuine `v1.0` project, which is this epic's defining failure mode.
**Guard.** `lint-milestone-window-drift.cjs` today keys on the `#{N,M}` heading-level quantifier; `getMilestoneInfo`'s regexes anchor on a literal `##` and therefore slip past it. **Phase 6 ships the token widening together with the consolidation**, never after — a guard added later measures a surface already cleaned and reports a zero it did not earn.
#### 7.3 Phase enumeration — *Enforced for the four named consumers (Phase 3, #3222); the fifth copy is unowned*
**Question.** Which directories under `<planning>/phases/` are phases of milestone `M`?
**Owner.** `src/phase-locator.cts` · `listMilestonePhaseDirs` (Decision 1).
**Rule.** A directory counts **iff all three** hold: its identifier parses per `src/phase-id.cts`; it is not a sentinel per `isSentinelPhaseId`; and its ROADMAP entry falls inside `M`'s window per §7.1. Both filters, in that order. Any surface answering "how many phases does this milestone have" reports the same set for the same input — the progress renderer, the roadmap analysis, the statistics command and the phase listing are not allowed to disagree.
**Consumers that must route through the owner.** `cmdRoadmapAnalyze`, `cmdProgressRender`, `cmdStats`, `cmdPhasesList`, **and `buildStateFrontmatter` / `syncStateFrontmatter`** — the fifth copy, reached by `state.record-session`, `state.sync`, `phase.complete` and every other state-mutating verb, which the epic's original scope did not name (coverage-audit gap 1).
**Status, precisely.** Phase 3 (#3222) enforced this rule for `cmdRoadmapAnalyze`, `cmdProgressRender`, `cmdStats` and `cmdPhasesList`, and its guard (`scripts/lint-phase-enumeration-drift.cjs`) found 54 violations where the epic scoped 4. **It did not reach `buildStateFrontmatter` / `syncStateFrontmatter`.** That copy is still live, still writes its answer to disk, and is now unowned by any phase — see Amendment 4's scope table, row 1.
**Note on #3204.** Routing `buildStateFrontmatter` through the owner will not by itself fix #3204: its defect is the discriminator one layer *above* enumeration — "is the ROADMAP's phase count safe to trust" — which misclassifies ordinary `## Overview` / `## Progress` headings as milestone sectioning. That discriminator **is** §7.1's `isMilestoneBoundedInRoadmap`. The enumeration routing and the discriminator replacement must ship together or the defect survives the consolidation.
#### 7.4 Phase completion — *Required — Phase 4, blocked*
**Question.** Is phase `P` complete?
**Owner.** `src/verification.cts` · `isPhaseComplete` (Decision 1).
**Rule.** `readVerificationStatus` is called **unconditionally**. Plan count is not a precondition: a phase with zero plans and a passing `*-VERIFICATION.md` is complete. The read path and the write path share this predicate, so "`phase.complete` succeeds while `init.manager` reports incomplete" is unrepresentable for identical input.
**OPEN QUESTION — does a ROADMAP checkbox override disk state? (#2957).** There are **three** completion implementations, not the two the epic recorded: `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter`, which computes completed phases from plan scanning alone and never consults the ROADMAP checkbox that `cmdRoadmapAnalyze` deliberately honors. Checkbox-override versus disk-strict is a **product decision**, and it is not made here.
**Forcing function.** Phase 4's drift guard fails while more than one completion predicate exists. It cannot be satisfied by consolidating two of three and leaving the third, and Phase 4 must not ship before #2957 is decided — a shared predicate that silently adopts whichever semantics its author happened to hold is a product decision made by typing order.
#### 7.5 Live-plan counting — *Enforced (Phase 1), with a known representation gap*
**Question.** How many plans in phase `P` are outstanding?
**Owner.** `src/plan-scan.cts` · `scanPhasePlans`, exposing `planFiles` (live) **and** `allPlanFiles` (every plan on disk, pre-supersession).
**Rule.** A plan is **live** unless it carries a machine-readable terminal state. The only terminal state today is frontmatter `status: superseded` (#2349). Choosing between the two sets is explicit per call site, never mechanical: **a diagnostic about file naming takes `allPlanFiles`; a question about outstanding work takes `planFiles`** (Amendment 1 — passing the filtered set into `describeNonCanonicalPlans` made a superseded-but-correctly-named plan report as a naming violation).
**GAP — the lifecycle has exactly one machine-readable terminal state (#1762, coverage-audit gap 6).** Plans retired through ROADMAP prose or HTML-comment fences carry no `status` key, so the canonical owner counts them live. Consolidation cannot fix this; it needs a representation that does not exist yet. The contract, locked now so no surface invents its own: **the plan lifecycle's terminal states are a closed, frontmatter-expressed vocabulary. Prose is not a lifecycle signal.** Until the vocabulary is extended, a plan a human considers retired but that carries no `status` key **is live**, and every surface reports it that way — a caller may not compensate by reading prose locally.
#### 7.6 Completion ratio — *arithmetic Enforced; rule 3 Enforced for `query progress` / `stats`; rule 4 Required — Phase 7*
**Question.** What percentage of a scoped set is complete?
**Owner.** `src/phase-lifecycle.cts` · `clampPercent(completed, total)` and `clampPercentFromFraction(fraction)`. A **seventh derivation family**, absent from the epic's table: the identical expression `total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0` was hand-inlined at six call sites across five modules while the owner sat exported beside them, unused by any of them.
**Rule.**
1. Exactly one expression of `fraction → integer percent` exists: round-half-up, ceiling 100.
2. A non-positive or absent denominator yields **0**. "Nothing to complete" is 0%, never 100%.
3. **The numerator and the denominator come from the same scoped set.** A percentage inherits the `scope` of the counts that produced it.
4. **A derivation whose scope is not `COMPLETE` does not render a percentage at all.**
**Status.**
- **Rules 1 and 2 — enforced.** Six sites migrated onto the owner, guarded by `scripts/lint-completion-ratio-drift.cjs`.
- **Rule 3 — enforced for `query progress` and `stats`.** Phase 3 (#3222) routed `cmdStats`'s and `cmdProgressRender`'s `totalPlans`/`totalSummaries` accumulation through `listMilestonePhaseDirs`'s scoped, sentinel-filtered set, so a backlog directory's already-summarized plans can no longer inflate the numerator against a milestone that has not finished. **That is what closed #3161**, and it is rule 3 by another name.
- **Rule 4 — Phase 7 (#3217).** Withholding a percentage entirely when the scope is not `COMPLETE` is not implemented anywhere. Phase 7 also re-checks any consumer Phase 3 did not reach, `cmdRoadmapAnalyze` first.
**Correction, recorded rather than quietly dropped.** Amendment 4 originally asserted that #3161 was *not* fixed by the arithmetic consolidation and that enumeration consolidation "changes nothing here" — and the second half was wrong. The two changes were authored concurrently; Phase 3 merged first and subsumed #3161 through the scoped set. The first half stands: the arithmetic consolidation alone would not have fixed it. Kept visible because a green ratio guard beside an unfixed #3161 would have been exactly the "measure became the target" outcome Decision 4 exists to prevent, and the reason it is not that outcome is Phase 3, not this change.
#### 7.7 State field extraction — *Required — Phase 5*
**Question.** What is the value of field `F` in a `.planning/` state document?
**Owner.** `src/state-document.cts` · `stateExtractField`, carrying the #1760 fallback chain.
**Rule.** Every consumer calls the owner; none re-derives the field's location or its fallback order locally. `state validate` reports invalid for a genuinely invalid document — an unconditional `{valid: true, warnings: [], drift: {}}` is a gate that cannot fail, which is worse than no gate (Decision 3).
**Call-site sweep is driven from the graph, not from the epic's text** — `find_symbol` reports 20 direct callers where #3180 says five.
## Consequences ## Consequences
**Positive.** "Fixed on one copy, missed on the siblings" becomes unrepresentable for five derivations. `phase.complete` succeeding while `init.manager` reports incomplete becomes structurally impossible rather than merely fixed. Failure paths stop being output-identical to success, so the suite can assert on them and the class of bug that required downstream dogfooding to find becomes detectable in CI. **Positive.** "Fixed on one copy, missed on the siblings" becomes unrepresentable for five derivations. `phase.complete` succeeding while `init.manager` reports incomplete becomes structurally impossible rather than merely fixed. Failure paths stop being output-identical to success, so the suite can assert on them and the class of bug that required downstream dogfooding to find becomes detectable in CI.
@@ -238,9 +356,26 @@ Considered and not applicable: `choose-boring-technology` (no new dependency; fi
- [ADR-2121](2121-phase-identifier-parsing-consolidation.md) — the proven precedent this extends - [ADR-2121](2121-phase-identifier-parsing-consolidation.md) — the proven precedent this extends
- [ADR-2143](2143-markdown-table-and-mutation-consolidation.md) — the document-parsing layer beneath - [ADR-2143](2143-markdown-table-and-mutation-consolidation.md) — the document-parsing layer beneath
- `scripts/lint-phase-id-drift.cjs` — the guard pattern Decision 4 models - `scripts/lint-phase-id-drift.cjs` — the guard pattern Decision 4 models
- `scripts/qa-smell-ratchet.cjs` — the ratchet invariants Decision 4(e) adopts verbatim
- `scripts/lib/drift-scan.cjs` — the one tree-walk/confinement/sanitizer implementation every guard shares
- `CONTRIBUTING.md` § *Prohibited: Raw Text Matching on Test Outputs* — why `scope` is a frozen enum - `CONTRIBUTING.md` § *Prohibited: Raw Text Matching on Test Outputs* — why `scope` is a frozen enum
- `CONTRIBUTING.md` § *Fixture provenance (#2371)* — why the identity test alone is insufficient - `CONTRIBUTING.md` § *Fixture provenance (#2371)* — why the identity test alone is insufficient
- Phase sub-issues: [#3183](https://github.com/open-gsd/gsd-core/issues/3183), [#3184](https://github.com/open-gsd/gsd-core/issues/3184), [#3185](https://github.com/open-gsd/gsd-core/issues/3185), [#3186](https://github.com/open-gsd/gsd-core/issues/3186), [#3187](https://github.com/open-gsd/gsd-core/issues/3187) - Phase sub-issues: [#3183](https://github.com/open-gsd/gsd-core/issues/3183), [#3184](https://github.com/open-gsd/gsd-core/issues/3184), [#3185](https://github.com/open-gsd/gsd-core/issues/3185), [#3186](https://github.com/open-gsd/gsd-core/issues/3186), [#3187](https://github.com/open-gsd/gsd-core/issues/3187), and the three added by Amendment 4 — [#3216](https://github.com/open-gsd/gsd-core/issues/3216) (Phase 6, §7.2), [#3217](https://github.com/open-gsd/gsd-core/issues/3217) (Phase 7, §7.6), [#3218](https://github.com/open-gsd/gsd-core/issues/3218) (Phase 8, §7.5 + Decision 4(d)/(e)).
### Guard roster
One row per derivation. A blank owner is a derivation whose contract is locked (§7) but whose owner does not exist yet.
| Derivation | Owner | Guard | Scan surface | Status |
|---|---|---|---|---|
| Milestone windowing (§7.1) | `roadmap-parser.cts` | `lint-milestone-window-drift.cjs` | `src/` | enforced |
| Milestone identity (§7.2) | — (Phase 6) | same guard, token set widened by Phase 6 | `src/` | contract only |
| Phase enumeration (§7.3) | `phase-locator.cts` | `lint-phase-enumeration-drift.cjs` | `src/` | enforced for the four named consumers; `buildStateFrontmatter`/`syncStateFrontmatter` **unowned** |
| Phase completion (§7.4) | `verification.cts` (Phase 4) | Phase 4 | `src/` | blocked on #2957 |
| Live-plan counting (§7.5) | `plan-scan.cts` | `lint-plan-count-drift.cjs` | `src/` | enforced |
| Live-plan counting, prompt layer (§7.5) | — (Phase 8) | `lint-planning-prompt-drift.cjs` | `gsd-core/workflows`, `commands`, `agents`, `skills` | ratcheted, 7 sites |
| Completion ratio (§7.6) | `phase-lifecycle.cts` | `lint-completion-ratio-drift.cjs` | `src/` | arithmetic + rule 3 enforced; rule 4 is Phase 7 |
| State field extraction (§7.7) | `state-document.cts` (Phase 5) | Phase 5 | `src/` | contract only |
## Amendments ## Amendments
@@ -470,3 +605,60 @@ recorded as adjudicated rather than fixed.
**Scope note.** Phase 3 is the last consumer of the enumeration/window layer; Phases 4 and 5 build **Scope note.** Phase 3 is the last consumer of the enumeration/window layer; Phases 4 and 5 build
on the completion and state-extraction derivations respectively and do not depend on on the completion and state-extraction derivations respectively and do not depend on
`listMilestonePhaseDirs`. `listMilestonePhaseDirs`.
### Amendment 4 — the 2026-08-08 coverage audit: two more derivation families, a fifth enumeration copy, and a guard that was looking at half the surface
> **Written before Phase 3 merged, reconciled against it after.** This amendment and Amendment 3
> were authored concurrently and reached the same conclusion independently — the copy count is
> always a lower bound and only the whole-surface guard makes it real (Amendment 3 found 54
> violations where the epic scoped 4). Amendment 3 states that rule; this one does not restate it.
> Three of this amendment's original claims were superseded by what Phase 3 actually shipped, and
> each is corrected in place below rather than left standing.
Source: the coverage audit posted to #3180 on 2026-08-08, which tested every open non-PR'd `bug`
on the tracker against one question — *would executing this epic's stated work, by itself, make the
reported symptom stop?* Four issues passed and were closed into the epic (#3164 and #3166 as already
discharged by Phases 1 and 2; #3167 and #3168 as covered by open Phases 3 and 4). Seven did not.
This amendment is what the seven change.
**What the audit added to the copy count.** A fifth enumeration copy, a third completion predicate,
and **two derivation families the epic never named at all**. Amendment 3 states the standing rule
this is the fourth instance of, and states it from a stronger position — 54 violations against a
scoped 4 — so it is not restated here.
**Scope changes.**
| # | Change | Why the existing phases do not cover it |
|---|---|---|
| 1 | ~~**Phase 3 widens** to include `buildStateFrontmatter` and `syncStateFrontmatter`~~ — **superseded: Phase 3 merged without them.** The fifth enumeration copy and #3204 are now unowned and need a phase of their own | Phase 3's Done-when named only `cmdProgressRender`, `cmdStats` and `cmdPhasesList`, and #3222 shipped exactly that. `buildStateFrontmatter`/`syncStateFrontmatter` appear nowhere in Amendment 3, and #3204 appears nowhere in this ADR outside this row. Routing alone would not have fixed #3204 anyway — its defect is the "is the ROADMAP count trustworthy" discriminator one layer *above* enumeration, which is §7.1's `isMilestoneBoundedInRoadmap` |
| 2 | **Phase 4 blocks on #2957** and its guard must fail while a third predicate exists | The audit found `buildStateFrontmatter` computing completed phases from plan scanning alone, ignoring the ROADMAP checkbox `cmdRoadmapAnalyze` honors. Checkbox-override vs disk-strict is an undecided product question, not a consolidation |
| 3 | **Phase 6 — milestone identity** (§7.2): bind `getMilestoneInfo` to `locateMilestoneHeadings`, widen the windowing guard's token set in the same change (#3171, #3197) | A sixth derivation family. Phase 2 consolidated *windowing*; `getMilestoneInfo` hand-rolls its own heading regexes to answer a different question — *which milestone is this and what is it called* — and no named phase touches it |
| 4 | **Phase 7 — completion-ratio scoping** (§7.6 rules 3–4). **Corrected: #3161 is NOT fixed here — Amendment 3 subsumed it.** Phase 7 keeps rule 4 and whatever consumers Phase 3 did not reach | A seventh derivation family. This amendment ships the arithmetic half. Its original claim — that enumeration consolidation "changes nothing here" — was **wrong**, and Phase 3 proved it: routing `cmdStats`/`cmdProgressRender`'s `totalPlans`/`totalSummaries` accumulation through `listMilestonePhaseDirs`'s scoped set *is* §7.6 rule 3 for those two consumers, and it is what closed #3161 |
| 5 | **Phase 8 — the prompt layer**: give the workflow layer a CLI surface to ask for plan and phase counts, and burn the ratchet baseline to zero | The re-derivation lives in shell inside `gsd-core/workflows/*.md`. No `.cts`-scoped guard can see it, and no import can route it — it needs a command to call |
| 6 | **Plan-lifecycle terminal states** (§7.5) are declared a closed frontmatter vocabulary, with the gap recorded rather than papered over | Consolidation cannot fix #1762's prose-retired plans; the canonical owner counts them live and is *correct* to, under the contract as written |
**Ordering.** Phase 6 is independent of Phases 3–5 and may ship at any point. Phase 7 follows Phase 3
(its scoping is what makes rules 3–4 expressible). Phase 8 follows whichever phase first exposes the
CLI surface it calls. Decision 5's locked 1→2→3→4→5 order is unchanged.
**What shipped in this amendment's own change.**
- Decision 4(d) — scan surface is every authored surface, and an owner **file** is no longer exempt, only its named canonical **functions**. `lint-milestone-window-drift.cjs` exempted `src/roadmap-parser.cts` wholesale, which is constraint (a)'s forbidden allowlist pointed at the file most likely to grow the next copy — and it had: `getMilestoneInfo` sits inside it, invisible.
- Decision 4(e) — the ratchet mechanism, so a surface that cannot be consolidated today is watched today.
- Decision 7 — the behavior contract, this ADR's normative core.
- **Completion ratio consolidated**: `clampPercentFromFraction` added beside `clampPercent`; six inline copies across `roadmap.cts`, `state.cts`, `commands.cts` (×2), `workstream-inventory-builder.cts`, `gsd2-import.cts` and `state-document.cts` migrated onto the owner; `scripts/lint-completion-ratio-drift.cjs` added, reporting zero re-derivations with no file-level exemption.
- **Prompt layer made visible**: `scripts/lint-planning-prompt-drift.cjs` added with a shrink-only baseline covering the 7 sites across `progress.md`, `execute-plan.md`, `plan-phase.md` and `plan-review-convergence.md`. **Phase 8 (#3218) owns their removal, and every baseline entry names it** — Decision 4(e) requires the acknowledgment to point at the issue that removes it, not at the epic.
**Decision 3's Tier-2 table, re-derived for this amendment: no rows.** Every percent migration is
behavior-identical — `clampPercent`'s first line *is* the `total > 0 ? … : 0` ternary each copy
carried. The single deliberate difference is `gsd2-import`'s `pct`, which gains a 100 ceiling it did
not have; `donePhases` is a subset count of `totalPhases`, so the ceiling is unreachable and no
emitted value changes.
**Explicitly NOT absorbed, and left open on their own issues.** #3165's *answer* remains unrepaired —
Phase 2 made the truncated window decidable (`SCOPE.TRUNCATED`) but `phase_count` is still `0` and
`current_phase`/`next_phase` still `null`, so its first acceptance criterion is unmet and the
underlying document-layout ambiguity is untouched by design. #3163 belongs to #2143's sectionizer
layer and is not enumerated there yet; #3169 and #3170 are standalone parser/extraction defects with
no epic home. Recording them here as *not covered* rather than leaving them to be re-tested by the
next audit.

View File

@@ -112,7 +112,7 @@
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "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 && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-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 && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs",
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.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:regression-names": "node scripts/lint-regression-test-names.cjs",
"lint:descriptions": "node scripts/lint-descriptions.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs",

View File

@@ -0,0 +1,47 @@
{
"$comment": "ADR-3180 Decision 4(e) ratchet, owned by Phase 8 (#3218). See scripts/lint-planning-prompt-drift.cjs. SHRINK-ONLY: entries are removed as sites migrate to the gsd-core CLI; new or changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.",
"entries": [
{
"file": "gsd-core/workflows/execute-plan.md",
"text": "(ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null || true) | wc -l",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 1
},
{
"file": "gsd-core/workflows/execute-plan.md",
"text": "(ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null || true) | wc -l",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 1
},
{
"file": "gsd-core/workflows/plan-phase.md",
"text": "DISK_PLANS=$(ls \"${PHASE_DIR}\"/*-PLAN.md 2>/dev/null | wc -l | tr -d ' ')",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 2
},
{
"file": "gsd-core/workflows/plan-review-convergence.md",
"text": "PLAN_COUNT=$(ls ${phase_dir}/${padded_phase}-*-PLAN.md 2>/dev/null | wc -l)",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 1
},
{
"file": "gsd-core/workflows/progress.md",
"text": "(ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null || true) | wc -l",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 1
},
{
"file": "gsd-core/workflows/progress.md",
"text": "(ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null || true) | wc -l",
"derivation": "plan-count",
"owner_issue": "#3218",
"count": 1
}
]
}

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
'use strict';
/**
* Anti-divergence drift guard for the completion-RATIO seam
* (epic #3180, ADR-3180 "Planning Semantic Model Single Owner").
*
* `src/phase-lifecycle.cts`'s `clampPercent(completed, total)` /
* `clampPercentFromFraction(fraction)` are the SINGLE canonical owner of
* "turn a completed/total pair into an integer completion percentage,
* clamped to 100". Until just before this guard was added, the identical
* expression `total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0`
* was hand-inlined at six call sites across five modules while the owner sat
* exported and unused by them — the exact ADR-3180 divergence class, in a
* derivation the epic had not previously named. Those six sites have been
* migrated onto the owner; this guard is what stops a seventh copy.
*
* 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, and a new call site added anywhere else would sail through silently.
*
* DETECTION. A line is a re-derivation when ALL THREE hold, on that ONE
* source line:
* (a) it calls one of the `Math.round(`/`Math.floor(`/`Math.trunc(`/
* `Math.ceil(` rounding family — MATH_ROUND_FAMILY_RE;
* (b) it SCALES by 100 — a `*` followed by optional whitespace then `100`
* at a word boundary — SCALE_100_RE;
* (c) it contains a DIVISION — an identifier/closing-bracket, optional
* whitespace, `/`, optional whitespace, an identifier/opening-paren —
* DIVISION_RE — AND that division's index in the line is EARLIER than
* the index of the `* 100` scale from (b).
*
* Clause (c)'s ORDERING requirement is the whole precision of this guard.
* `(a / b) * 100` — divide FIRST, scale SECOND — is a percentage: the
* completed/total-derived shape this guard exists to catch. `Math.round(n *
* 100) / 100` — scale FIRST, divide SECOND — is a completely unrelated
* idiom (2-decimal-place rounding of an already-fractional value) that
* happens to share both a rounding call and a `* 100` token; it appears in
* this repo at `src/eval.cts` and `src/commands.cts` and MUST stay
* unflagged. Comparing leftmost-match indices (rather than merely testing
* "does a division exist anywhere on the line") is what tells the two
* idioms apart: this guard finds the EARLIEST division and the EARLIEST
* `* 100` scale on the line and requires divIdx < scaleIdx, so a line with a
* scale-then-divide shape (divIdx > scaleIdx, or no division at all) never
* matches, regardless of what else is on the line.
*
* `Math.floor(Math.random() * 100)` carries (a) and (b) but no division
* anywhere on the line (DIVISION_RE finds nothing, divIdx === -1) and is
* correctly excluded by clause (c) alone.
*
* `src/context-utilization.cts`'s `Math.min(Math.round(ratio * 100), 100)`
* is OUT OF SCOPE BY DOMAIN, not by exemption: it scales an
* ALREADY-COMPUTED fraction (`ratio`, a context-window utilization figure —
* unrelated to `.planning/` phase/plan completion) and there is no division
* anywhere on that line either, so clause (c) excludes it the same way as
* the `Math.random()` case above; it needs no FUNCTION_SCOPED_EXEMPTIONS
* entry because it was never going to match.
*
* Every regex below is small, bounded, and has no nested/overlapping
* quantifiers — each character class is followed by a fixed literal or a
* single `\s*` run bounded by the next required literal, so there is
* nothing for a backtracking engine to explore more than linearly.
* `npm run lint:ci` runs CodeQL js/redos over this repo; mirrors the
* ReDoS discipline of `lint-plan-count-drift.cjs` / `lint-milestone-window-drift.cjs`.
*
* The tree-walk / root-confinement / sanitizer machinery is SHARED with the
* sibling drift guards via `scripts/lib/drift-scan.cjs` (ADR-3180 Decision 4)
* — see that module for the `isInsideRoot` case-sensitivity note and the
* `walk` symlink-confinement rationale. This guard's detection shape needs
* no regex-LITERAL extraction (unlike the milestone-window guard), so it
* does not use `readRegexLiteralAt`; the reported fragment is simply the
* trimmed source line, bounded to MAX_REGEX_LITERAL_LEN characters.
*
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the
* sibling drift guards document): a re-derivation whose division and
* `Math.round`/scale are split across two DIFFERENT lines with no single
* line carrying all three tokens is not caught by this narrow shape, nor is
* one routed through a helper that itself performs the division one call
* away from the rounding. That is left to code review, not this regex.
*/
const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
// (a) The `Math.round`/`Math.floor`/`Math.trunc`/`Math.ceil` rounding family,
// called with an open paren. `\b` before `Math` keeps this from matching
// inside a longer identifier (e.g. `fooMath.round(` never occurs in this
// codebase, but the boundary costs nothing and documents intent).
const MATH_ROUND_FAMILY_RE = /\bMath\.(?:round|floor|trunc|ceil)\(/;
// (b) A `* 100` scale — a `*` operator, optional whitespace, then the
// literal digits `100` at a word boundary (so `*1000` or `*100.5` do not
// match a bare `100` inside a longer number).
const SCALE_100_RE = /\*\s*100\b/;
// (c) A division: an identifier character/closing-bracket (the end of the
// numerator expression), optional whitespace, `/`, optional whitespace, an
// identifier character/opening-paren (the start of the denominator
// expression). Deliberately does not try to distinguish this from a regex
// literal or a `//` comment — the detection window is a Math.round-family
// call on the same line, which neither idiom co-occurs with in practice, and
// keeping the class small is what keeps the regex non-backtracking.
const DIVISION_RE = /[A-Za-z0-9_$)\]]\s*\/\s*[A-Za-z0-9_$(]/;
// 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 ratio-to-percent grammar. It is NOT
// exempt as a whole file (ADR-3180 Decision 4(a) forbids bare file
// allowlists) — it is scanned like every other file in SCAN_DIRS, and only
// the two named functions below are exempt, each for a documented reason.
// An unrelated re-derivation added elsewhere in this same file (including a
// future one) is still caught.
const OWNER_FILE = path.join('src', 'phase-lifecycle.cts');
// Per ADR-3180 Decision 4(a): function-scoped, not a bare file allowlist.
// - clampPercentFromFraction: `Math.min(100, Math.round(fraction * 100))`
// IS the canonical fraction-to-percent kernel this guard exists to
// protect, not a copy of it — every other caller in the tree is
// expected to CALL this function rather than re-express its body.
// - clampPercent: the canonical count-shaped entry point; it delegates to
// `clampPercentFromFraction(completed / total)` rather than computing
// `Math.round(...)` itself, so it is exempted for the same reason even
// though its own line does not currently carry a Math.round-family call.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([[OWNER_FILE, new Set(['clampPercent', 'clampPercentFromFraction'])]]);
// Optional `export ` modifier, matching the sibling guards' convention —
// only a column-0 top-level `function` declaration updates the
// current-function tracker.
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
/**
* Pure: find every unsanctioned completion-ratio re-derivation in `text`.
* `relPath` is the repo-relative path, used both to report file:line and to
* apply the narrow, function-scoped owner exemptions above.
* Returns [{ line, found }].
*/
function findCompletionRatioDrift(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 (!MATH_ROUND_FAMILY_RE.test(line)) continue;
const scaleIdx = line.search(SCALE_100_RE);
if (scaleIdx === -1) continue;
const divIdx = line.search(DIVISION_RE);
if (divIdx === -1 || divIdx >= scaleIdx) continue;
if (exemptFunctions && exemptFunctions.has(currentFunction)) continue;
out.push({ line: i + 1, found: line.trim().slice(0, MAX_REGEX_LITERAL_LEN) });
}
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 — and
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
// consistently regardless of which symlink reached the file.
return findCompletionRatioDrift(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 completion-ratio-drift: no unsanctioned completed/total percent re-derivations outside phase-lifecycle.cts\n');
return;
}
process.stderr.write('completion-ratio-drift: independent re-derivation(s) of completed/total percent found.\n');
process.stderr.write('Use src/phase-lifecycle.cjs `clampPercent(completed, total)` (or `clampPercentFromFraction(fraction)`\n');
process.stderr.write('when you already hold a fraction) instead of re-deriving Math.round((completed / total) * 100):\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 line text 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 = {
findCompletionRatioDrift,
scanRepo,
MATH_ROUND_FAMILY_RE,
SCALE_100_RE,
DIVISION_RE,
OWNER_FILE,
FUNCTION_SCOPED_EXEMPTIONS,
MAX_REGEX_LITERAL_LEN,
};

View File

@@ -59,12 +59,26 @@
* quoted/backticked strings (not shared — `lint-plan-count-drift.cjs` has no * quoted/backticked strings (not shared — `lint-plan-count-drift.cjs` has no
* equivalent need, since its own literal-bearing shape is regex-only). * equivalent need, since its own literal-bearing shape is regex-only).
* *
* Owner file (exempt by construction): `src/roadmap-parser.cts` — it not only * Owner file: `src/roadmap-parser.cts` DEFINES this grammar, but it is NOT
* DEFINES this grammar but composes `#{1,3}` with `(?!Phase...)`/marker * exempt as a whole file — that was the original design (a bare per-file
* alternations at several internal call sites (`computeMilestoneSectionEnd`, * allowlist) and it closed off exactly the blind spot ADR-3180 Decision 4(a)
* `locateMilestoneHeadings`, `extractCurrentMilestoneScoped`'s * warns about: `getMilestoneInfo`, added later in this same owner file,
* `anyMilestonePattern`/`anyMilestoneOrDetails`) that are the canonical * hand-rolled its own milestone-heading regex (issues #3171, #3197) and the
* implementation, not copies of it. * whole-file exemption made it invisible to this guard. The owner file is now
* scanned like every other file in SCAN_DIRS; only its named canonical
* functions are exempt (`FUNCTION_SCOPED_EXEMPTIONS`, keyed on `OWNER_FILE`),
* each with a written reason — `isMilestoneShippedInRoadmap`,
* `locateMilestoneHeadings`, `hasMilestoneSectioning`, and
* `extractCurrentMilestoneScoped` (whose `anyMilestonePattern`/
* `anyMilestoneOrDetails` locals compose `#{1,3}` with the `(?!Phase...)`/
* marker alternations as part of the canonical implementation, not a copy of
* it). `computeMilestoneSectionEnd` carries (a) and (b) on two DIFFERENT
* lines (the heading-quantifier match and the version/marker test are two
* separate statements) rather than one line carrying both, so this guard's
* own documented per-line-scan limit means it never fires there and it needs
* no listed exemption. An unrelated re-derivation added anywhere else
* in this file — including inside a function added after this guard, such as
* a future `getMilestoneInfo`-shaped one — is still caught.
* *
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer * The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
* machinery is SHARED with `scripts/lint-plan-count-drift.cjs` via * machinery is SHARED with `scripts/lint-plan-count-drift.cjs` via
@@ -156,9 +170,35 @@ const OWNER_FILE = path.join('src', 'roadmap-parser.cts');
// does not itself carry token (b) as this guard defines it (no // does not itself carry token (b) as this guard defines it (no
// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption // `(?!Phase` lookahead, no marker-emoji pairing) — this exemption
// currently documents intent rather than suppressing a live match. // currently documents intent rather than suppressing a live match.
// - roadmap-parser.cts isMilestoneShippedInRoadmap: composes the heading
// quantifier with the shipped/active MARKER check (via
// isClosedMilestoneHeading) to answer "is THIS milestone version marked
// shipped by the ROADMAP" — a documented, narrower question than
// computeMilestoneSectionEnd/locateMilestoneHeadings' "where does it
// end"/"which heading is it", not a copy of either.
// - roadmap-parser.cts locateMilestoneHeadings: this literally IS the
// canonical heading-locator this guard exists to protect (see the
// function's own header comment) — every other module's heading lookup
// is expected to call it, not re-express it.
// - roadmap-parser.cts hasMilestoneSectioning: the canonical "does this
// ROADMAP use milestone sectioning at all" predicate — a deliberately
// WEAKER, version-agnostic composition of the same two tokens, owned
// here per its own header comment so the milestone-heading vocabulary
// has one home rather than a third hand-rolled copy in state.cts.
// - roadmap-parser.cts extractCurrentMilestoneScoped: its
// `anyMilestoneOrDetails`/`anyMilestonePattern` locals are the two
// internal call sites the header comment already names as part of the
// canonical implementation (composing `#{1,3}` with the
// `(?!Phase...)`/marker alternations to find "the next milestone
// boundary" while assembling the current-milestone window) — not
// re-derivations of a question answered elsewhere.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])], [path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])],
[path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])], [path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])],
[
OWNER_FILE,
new Set(['isMilestoneShippedInRoadmap', 'locateMilestoneHeadings', 'hasMilestoneSectioning', 'extractCurrentMilestoneScoped']),
],
]); ]);
// Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s // Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s
@@ -287,10 +327,12 @@ function scanRepo(root) {
scanExt: SCAN_EXT, scanExt: SCAN_EXT,
onFile(rel, text) { onFile(rel, text) {
// `rel` is already the REAL (canonical) path (scanTree resolves // `rel` is already the REAL (canonical) path (scanTree resolves
// symlinks before calling onFile), so this comparison — and // symlinks before calling onFile), so this — and
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match // FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
// consistently regardless of which symlink reached the file. // consistently regardless of which symlink reached the file. The owner
if (rel === OWNER_FILE) return []; // file is NOT short-circuited here; it is scanned like every other
// file, and only its named canonical functions are exempt (see
// FUNCTION_SCOPED_EXEMPTIONS).
return findMilestoneWindowDrift(text, rel).map((d) => ({ file: rel, ...d })); return findMilestoneWindowDrift(text, rel).map((d) => ({ file: rel, ...d }));
}, },
}); });

View File

@@ -0,0 +1,434 @@
#!/usr/bin/env node
'use strict';
/**
* Anti-divergence drift guard for the PROMPT-LAYER plan/summary-COUNTING seam
* (epic #3180, ADR-3180 "Planning Semantic Model Single Owner", Decision 4(e)).
*
* `scripts/lint-plan-count-drift.cjs` and `scripts/lint-milestone-window-drift.cjs`
* scan `src/` only — but the `.planning/` semantic derivations they own are ALSO
* re-derived a second time, in the PROMPT layer: the workflow markdown that
* ships to every runtime, authored as raw shell rather than TypeScript. Issue
* #1762's second reproduction traced a wrong `30 plans, 24 summaries` figure to
* a `ls -1 ... *-PLAN.md | wc -l` snippet in `gsd-core/workflows/progress.md` —
* a re-derivation no `.cts`-scoped guard can see, because it is markdown, not
* source. ADR-3180 Decision 4(a) requires whole-repo discovery; this guard
* extends that requirement from "the whole `src/` tree" to "every authored
* surface that can carry a derivation", covering the prompt layer the two
* sibling guards structurally cannot reach.
*
* Detection is intentionally NARROW, mirroring the sibling guards' precedent:
* a line is a re-derivation when it carries BOTH, in ONE source line:
* (a) a plan/summary SET GLOB — a `*` followed by a run of
* `[-A-Za-z0-9_.{}$]` characters and then the literal `PLAN.md` or
* `SUMMARY.md`. The leading `*` is load-bearing: it is what makes the
* line enumerate a SET of files rather than name one specific plan.
* `gsd-core/workflows/execute-plan.md`'s
* `grep -cE '^\s*<task[[:space:]>]' .../{phase}-{plan}-PLAN.md` counts
* TASKS *inside* one already-named plan file — it has no glob token
* (no `*` anywhere near `PLAN.md`), so it is not a plan-count
* re-derivation and correctly never matches (a).
* (b) a COUNTING operation on that same line — `wc -l`, or `grep -c`
* (optionally with bundled short flags, e.g. `grep -cE`). Reading,
* globbing, or merely LISTING plan/summary files (`ls *-PLAN.md`,
* `cat *-PLAN.md`, `--files ".../*-PLAN.md"`) without counting them is
* not this derivation and must not be flagged — every non-counting
* `*-PLAN.md`/`*-SUMMARY.md` glob in `gsd-core/workflows/plan-phase.md`
* (backup, `--files`, `cat`, cross-reference prose) is exactly this
* shape and is deliberately left alone.
* `*-UAT.md` never matches (a) — UAT artifacts are a different derivation
* this guard does not own — so `gsd-core/workflows/progress.md`'s
* `... *-UAT.md ... | wc -l` line correctly never fires even though it sits
* one line below two lines that DO.
*
* Both regexes are small, bounded, and non-backtracking by construction (a
* single fixed character class with no nested quantifiers) — `npm run
* lint:ci` runs CodeQL js/redos over this repo, the same discipline the
* sibling guards document in their own headers.
*
* Surfaces scanned (SCAN_DIRS): `gsd-core/workflows`, `commands`, `agents`,
* `skills` — the prompt-layer markdown that ships to runtimes. SCAN_EXT:
* `.md` only. The tree-walk / root-confinement / symlink / sanitizer
* machinery is SHARED with the two sibling guards via `scripts/lib/drift-scan.cjs`
* (ADR-3180 Decision 4'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 ReDoS-avoidance rationale for its regex-literal reader
* (unused by this guard's own regexes, which need no literal tokenizer, but
* shared for the tree walk and report sanitization).
*
* RATCHET, not an allowlist. Per ADR-3180 Decision 4(e) this guard's baseline
* (`scripts/baselines/planning-prompt-drift-baseline.json`) mirrors
* `scripts/qa-smell-ratchet.cjs`'s precedent exactly: a violation whose
* `(file, text)` pair is already RECORDED in the baseline is KNOWN and never
* fails; a violation whose pair is NOT recorded is NEW and fails, telling the
* author to route the count through the `gsd-core` CLI instead of re-deriving
* it in shell; a recorded pair that no longer fires in this run is STALE and
* ALSO fails, forcing `--update` (run by a maintainer after a migration) to
* prune it — this is what makes the baseline SHRINK-ONLY as call sites
* migrate off the shell re-derivation, rather than a list that only ever
* grows. Matching is keyed on the pair (`file`, TRIMMED source `text`), never
* the line number: a workflow markdown file's line numbers churn on every
* unrelated edit (a new paragraph, a reworded step) and a number-keyed
* baseline would need hand-maintenance on changes that have nothing to do
* with this derivation at all.
*
* COUNT, not duplicate rows. Two DIFFERENT source lines can carry the exact
* same (file, TRIMMED text) pair — `gsd-core/workflows/plan-phase.md` has two
* byte-identical `DISK_PLANS=$(ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null | wc -l
* | tr -d ' ')` sites. Keying on (file, text) alone with one baseline row per
* OCCURRENCE made a partial migration invisible: migrating ONE of the two
* sites still leaves a violation matching the row, so nothing goes fresh and
* nothing goes stale — the remaining, unmigrated copy is silently covered by
* the row meant to acknowledge the pair NO LONGER MIGRATING. Each baseline
* entry therefore carries a `count` — the number of byte-identical
* occurrences of that (file, text) pair acknowledged at this site, not a
* duplicated row per occurrence:
* - actual occurrences this run < entry.count -> STALE as a PARTIAL
* migration: some but not all acknowledged copies are gone, so the entry
* no longer describes reality and must be re-recorded via `--update`;
* - actual occurrences this run > entry.count -> the occurrences beyond
* the acknowledged count are FRESH: a new copy landed next to one that
* was already acknowledged;
* - actual occurrences this run === 0 -> fully STALE, the
* existing "site was migrated, delete the row" case;
* - actual occurrences this run === entry.count -> fully acknowledged, no
* failure.
* Line numbers stay OUT of the key even with counting — that is still what
* keeps the baseline immune to unrelated churn; `count` answers "how many",
* never "which lines".
*
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the
* sibling guards document): a re-derivation whose glob and counting operator
* are split across two DIFFERENT lines (e.g. a variable holding the glob,
* counted via `wc -l` on the next line) is not caught by this narrow shape.
* That is left to code review, not this regex.
*/
const fs = require('node:fs');
const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { sanitizeForReport, scanTree } = driftScan;
// (a) A plan/summary SET GLOB: a `*` followed by a bounded run of path/brace/
// var-interpolation characters and then the literal `PLAN.md` or
// `SUMMARY.md`. The character class is fixed and the quantifier is a single
// `*` (regex "zero or more", not the shell glob character being matched) over
// that one class — no nesting, no alternation inside a repeated group, so
// there is nothing here for a backtracking engine to explore more than once.
const PLAN_SUMMARY_GLOB_RE = /\*[-A-Za-z0-9_.{}$]*(?:PLAN|SUMMARY)\.md/;
// `scanTree` (scripts/lib/drift-scan.cjs) builds its repo-relative path via
// `path.relative()`, which uses NATIVE separators: on Windows that is
// `gsd-core\workflows\execute-plan.md`, while the committed baseline
// (`scripts/baselines/planning-prompt-drift-baseline.json`) stores POSIX
// paths (`gsd-core/workflows/execute-plan.md`). Every baseline lookup in this
// guard is keyed on that path, so an un-normalized Windows path silently
// fails to match ANY baseline entry — every real violation reports as FRESH
// and every baseline entry reports as STALE (100% failure rate on Windows,
// caught by GitHub Actions' Windows CI lane on PR #3223; the remote runner
// this repo otherwise gates on is Linux-only and cannot see this class).
// Normalized UNCONDITIONALLY — never gated on `process.platform` — because a
// platform-conditional normalizer is itself the bug: it makes the POSIX path
// the tested case and leaves the Windows branch exercised only on Windows.
// Applied at the single seam `findPromptDrift` owns (the only place a
// repo-relative path enters this guard's violation objects), so ONE
// normalized value flows into all four consumers: the baseline key
// (`diffAgainstBaseline`), the `--update` writer (`writeBaseline` via
// `dedupeViolationsForBaseline`), the violation report (`main`), and the
// tests.
function toPosixRel(relPath) {
return relPath.replace(/\\/g, '/');
}
// (b) A counting operation: `wc -l`, or `grep -c` optionally followed by
// bundled short flags before the next space (e.g. `grep -cE`, `grep -cE`).
// `[A-Za-z]{0,4}` bounds the bundled-flag run so the alternative branch is
// exactly as fixed-width-bounded as `wc -l` — no unbounded quantifier chained
// to another, so nothing to backtrack.
const COUNTING_OP_RE = /wc -l|grep -c[A-Za-z]{0,4}\b/;
// Prompt-layer markdown that ships to every runtime.
const SCAN_DIRS = ['gsd-core/workflows', 'commands', 'agents', 'skills'];
const SCAN_EXT = new Set(['.md']);
const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'planning-prompt-drift-baseline.json');
// ADR-3180 Decision 4(e): a baseline entry is "acknowledged, in writing, with
// the issue that owns its removal" — that is Phase 8 (#3218, "the prompt
// layer": give the workflow layer a CLI surface to ask for plan and phase
// counts, and burn this ratchet baseline to zero), NOT the epic (#3180)
// itself. #3180 is the scope authority for the whole consolidation; #3218 is
// the phase that actually deletes these shell re-derivations.
const RATCHET_OWNER_ISSUE = '#3218';
/**
* Pure: find every plan/summary-count re-derivation line in `text`.
* `relPath` is the repo-relative path (native separators or POSIX, either
* is accepted) — normalized via `toPosixRel` and attached as `file` on every
* result; this function applies no per-file exemption, so `relPath` is not
* otherwise consulted for detection.
* Returns [{ file, line, found, text }] — `file` is always POSIX-separated,
* `text` is the TRIMMED source line, the same value the baseline keys on.
*/
function findPromptDrift(text, relPath) {
const file = toPosixRel(relPath);
const out = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const globMatch = PLAN_SUMMARY_GLOB_RE.exec(line);
if (!globMatch) continue;
if (!COUNTING_OP_RE.test(line)) continue;
out.push({ file, line: i + 1, found: globMatch[0], text: line.trim() });
}
return out;
}
/**
* Scan the prompt-layer markdown tree and return every re-derivation, each
* annotated with the repo-relative file path (POSIX-normalized — see
* `toPosixRel`).
*/
function scanRepo(root) {
return scanTree({
root,
scanDirs: SCAN_DIRS,
scanExt: SCAN_EXT,
onFile(rel, text) {
return findPromptDrift(text, rel);
},
});
}
/**
* Read and parse the ratchet baseline. Returns `{ entries, errors }` —
* `entries` is `[]` and `errors` names the problem when the file is missing,
* empty, invalid JSON, or malformed; callers in check mode treat a non-empty
* `errors` as a hard failure (mirrors `qa-smell-ratchet.cjs`'s `readBaseline`).
*/
function loadBaseline(root) {
const baselinePath = path.join(root, BASELINE_REL_PATH);
if (!fs.existsSync(baselinePath)) {
return { entries: [], errors: [`${BASELINE_REL_PATH} is missing — run \`node scripts/lint-planning-prompt-drift.cjs --update\` to generate it`] };
}
const raw = fs.readFileSync(baselinePath, 'utf8');
if (raw.trim() === '') {
return { entries: [], errors: [`${BASELINE_REL_PATH} is present but empty`] };
}
let doc;
try {
doc = JSON.parse(raw);
} catch (err) {
return { entries: [], errors: [`${BASELINE_REL_PATH} is not valid JSON: ${err.message}`] };
}
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
return { entries: [], errors: [`${BASELINE_REL_PATH} must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`] };
}
if (!Array.isArray(doc.entries)) {
return { entries: [], errors: [`${BASELINE_REL_PATH}: "entries" must be an array, got ${JSON.stringify(doc.entries)}`] };
}
const errors = [];
const entries = [];
doc.entries.forEach((entry, i) => {
const where = `${BASELINE_REL_PATH}.entries[${i}]`;
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${where} must be an object, got ${JSON.stringify(entry)}`);
return;
}
if (typeof entry.file !== 'string' || entry.file === '') {
errors.push(`${where}.file must be a non-empty string, got ${JSON.stringify(entry.file)}`);
return;
}
if (typeof entry.text !== 'string' || entry.text === '') {
errors.push(`${where}.text must be a non-empty string, got ${JSON.stringify(entry.text)}`);
return;
}
// `count` is optional on read (diffAgainstBaseline defaults an absent
// count to 1) but when present must be a positive integer — the number
// of byte-identical (file, text) occurrences this entry acknowledges.
if (entry.count !== undefined && !(Number.isInteger(entry.count) && entry.count >= 1)) {
errors.push(`${where}.count must be a positive integer when present, got ${JSON.stringify(entry.count)}`);
return;
}
entries.push(entry);
});
return { entries, errors };
}
/**
* Diff scanned `violations` (from `scanRepo`) against baseline `entries`,
* matched by the pair (`file`, TRIMMED `text`) — never the line
* number — and COUNT-aware: an entry acknowledges `entry.count`
* (default 1 when absent) byte-identical occurrences of that pair, not
* merely its presence. Returns `{ fresh, stale }`:
* - `fresh`: violations whose (file, text) pair is NOT in the baseline at
* all (a brand new site), PLUS any occurrences of a KNOWN pair beyond
* its acknowledged `count` (a new copy landed next to an
* already-acknowledged one) — both fail the build as NEW.
* - `stale`: baseline entries whose actual occurrence count this run is
* LESS than their acknowledged `count` — zero actual
* occurrences is the fully-migrated case ("site was migrated, delete
* the row"); a positive but short count is a PARTIAL migration (some
* but not all acknowledged copies are gone). Both fail the build,
* forcing `--update` to re-record the pair (this is what keeps the
* baseline shrink-only and what makes a partial migration visible
* instead of silently covered by the still-present sibling
* occurrence).
*/
function diffAgainstBaseline(violations, baseline) {
const key = (file, text) => `${file}${text}`;
// Group this run's violations by (file, text) so a duplicated pair's
// occurrence COUNT — not merely its presence — can be
// compared against what the baseline entry acknowledges.
const actualByKey = new Map();
for (const v of violations) {
const k = key(v.file, v.text);
let vs = actualByKey.get(k);
if (!vs) { vs = []; actualByKey.set(k, vs); }
vs.push(v);
}
const knownKeys = new Set(baseline.map((e) => key(e.file, e.text)));
const fresh = [];
const stale = [];
// Every occurrence of a pair the baseline has never recorded at all is NEW.
for (const [k, vs] of actualByKey) {
if (!knownKeys.has(k)) fresh.push(...vs);
}
// For every RECORDED pair, compare its acknowledged count against how
// many occurrences this run actually found.
for (const entry of baseline) {
const k = key(entry.file, entry.text);
const expected = entry.count ?? 1;
const vs = actualByKey.get(k) || [];
const actual = vs.length;
if (actual < expected) {
// Zero actual occurrences is the fully-stale case; 0 < actual <
// expected is a partial migration — both are STALE, and
// both carry the expected/actual counts so the caller can name the
// mismatch.
stale.push({ ...entry, count: expected, actualCount: actual });
} else if (actual > expected) {
// Occurrences beyond the acknowledged count are a NEW copy landing
// next to one that was already acknowledged.
fresh.push(...vs.slice(expected));
}
// actual === expected: fully acknowledged, no failure.
}
return { fresh, stale };
}
/** Stable sort: by `file`, then by `text`. */
function sortEntries(entries) {
return [...entries].sort((a, b) => {
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
if (a.text !== b.text) return a.text < b.text ? -1 : 1;
return 0;
});
}
/**
* Collapse `violations` into one baseline row per distinct (file, text) pair,
* carrying a `count` of how many occurrences that pair has in THIS run — see
* the module header's "COUNT, not duplicate rows" note. Pure; no I/O.
*/
function dedupeViolationsForBaseline(violations) {
const order = [];
const byKey = new Map();
for (const v of violations) {
const k = `${v.file}${v.text}`;
let entry = byKey.get(k);
if (!entry) {
entry = { file: v.file, text: v.text, derivation: 'plan-count', owner_issue: RATCHET_OWNER_ISSUE, count: 0 };
byKey.set(k, entry);
order.push(entry);
}
entry.count += 1;
}
return order;
}
function writeBaseline(root, violations) {
const entries = sortEntries(dedupeViolationsForBaseline(violations));
const doc = {
$comment:
'ADR-3180 Decision 4(e) ratchet, owned by Phase 8 (#3218). See scripts/lint-planning-prompt-drift.cjs. '
+ 'SHRINK-ONLY: entries are removed as sites migrate to the gsd-core CLI; new or changed entries fail '
+ 'lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site '
+ '— a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.',
entries,
};
const baselinePath = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
fs.writeFileSync(baselinePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
return entries;
}
function main() {
const root = path.join(__dirname, '..');
const update = process.argv.includes('--update');
const violations = scanRepo(root);
if (update) {
const entries = writeBaseline(root, violations);
process.stdout.write(`ok planning-prompt-drift: baseline regenerated with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`);
return;
}
const { entries: baseline, errors } = loadBaseline(root);
if (errors.length > 0) {
process.stderr.write('planning-prompt-drift: baseline load error(s):\n');
for (const e of errors) process.stderr.write(` ${e}\n`);
process.exitCode = 1;
return;
}
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
if (fresh.length === 0 && stale.length === 0) {
process.stdout.write(`ok planning-prompt-drift: no unacknowledged plan/summary count re-derivations in the prompt layer (${baseline.length} known)\n`);
return;
}
if (fresh.length > 0) {
process.stderr.write('planning-prompt-drift: NEW plan/summary count re-derivation(s) found in the prompt layer.\n');
process.stderr.write('Route the count through the gsd-core CLI instead of re-deriving it in shell (ls .../*-PLAN.md | wc -l\n');
process.stderr.write('or grep -c on a *-PLAN.md/*-SUMMARY.md glob), or add an acknowledged entry to\n');
process.stderr.write(`${BASELINE_REL_PATH} via --update:\n`);
for (const v of fresh) {
process.stderr.write(` ${sanitizeForReport(v.file)}:${v.line} ${sanitizeForReport(v.found)} ${sanitizeForReport(v.text)}\n`);
}
}
if (stale.length > 0) {
process.stderr.write('\nplanning-prompt-drift: STALE baseline entr' + (stale.length === 1 ? 'y' : 'ies') + " (fully migrated, or a PARTIAL migration — fewer occurrences found than acknowledged; delete or re-record the row):\n");
for (const e of stale) {
process.stderr.write(` ${sanitizeForReport(e.file)} ${sanitizeForReport(e.text)} (found ${e.actualCount}/${e.count} acknowledged occurrence${e.count === 1 ? '' : 's'})\n`);
}
process.stderr.write(`\n remedy: node scripts/lint-planning-prompt-drift.cjs --update\n`);
}
process.exitCode = 1;
}
if (require.main === module) main();
module.exports = {
findPromptDrift,
scanRepo,
toPosixRel,
loadBaseline,
diffAgainstBaseline,
writeBaseline,
PLAN_SUMMARY_GLOB_RE,
COUNTING_OP_RE,
SCAN_DIRS,
SCAN_EXT,
BASELINE_REL_PATH,
};

View File

@@ -1096,4 +1096,11 @@ module.exports = {
makeFileWeigher, makeFileWeigher,
packChunks, packChunks,
DEFAULT_TIMINGS_PATH, DEFAULT_TIMINGS_PATH,
// Exported so callers (tests/ci-test-scope.test.cjs) can assert the
// suite-token resolution contract in-process rather than through a timed
// subprocess spawn. Pure selection logic only — no behavior change.
parseArgs,
selectExplicitFiles,
selectFiles,
walkTestFiles,
}; };

View File

@@ -48,6 +48,7 @@ import modelProfiles = require('./model-profiles.cjs');
const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles; const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs'; import { realClock } from './clock.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import planScanMod = require('./plan-scan.cjs'); import planScanMod = require('./plan-scan.cjs');
const { scanPhasePlans } = planScanMod; const { scanPhasePlans } = planScanMod;
@@ -1595,7 +1596,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
} }
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; const percent = clampPercent(totalSummaries, totalPlans);
if (format === 'table') { if (format === 'table') {
// Render markdown table // Render markdown table
@@ -1951,8 +1952,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number)); const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
const completedPhases = phases.filter(p => p.status === 'Complete').length; const completedPhases = phases.filter(p => p.status === 'Complete').length;
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; const planPercent = clampPercent(totalSummaries, totalPlans);
const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0; const percent = clampPercent(completedPhases, phases.length);
// Requirements stats // Requirements stats
let requirementsTotal = 0; let requirementsTotal = 0;

View File

@@ -24,6 +24,7 @@ import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs'; import { platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs'; import { realClock } from './clock.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtilsMod = require('./core-utils.cjs'); import coreUtilsMod = require('./core-utils.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -373,7 +374,9 @@ function buildStateMd(phaseMap: PhaseMapEntry[]): string {
const currentEntry = phaseMap.find(p => !p.slice.done); const currentEntry = phaseMap.find(p => !p.slice.done);
const totalPhases = phaseMap.length; const totalPhases = phaseMap.length;
const donePhases = phaseMap.filter(p => p.slice.done).length; const donePhases = phaseMap.filter(p => p.slice.done).length;
const pct = totalPhases > 0 ? Math.round((donePhases / totalPhases) * 100) : 0; // ADR-3180 D7: one owner for completion percent. clampPercent's 100 ceiling is
// unreachable here (donePhases is a subset of totalPhases) — the value is unchanged.
const pct = clampPercent(donePhases, totalPhases);
const currentPhaseNum = currentEntry ? zeroPad(currentEntry.phaseNum) : zeroPad(totalPhases); const currentPhaseNum = currentEntry ? zeroPad(currentEntry.phaseNum) : zeroPad(totalPhases);
const currentSlug = currentEntry ? slugify(currentEntry.slice.title) : 'complete'; const currentSlug = currentEntry ? slugify(currentEntry.slice.title) : 'complete';

View File

@@ -113,11 +113,32 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre
return { completedPhases, totalPhases, totalPlans }; return { completedPhases, totalPhases, totalPlans };
} }
/**
* Compute progress percent clamped to 100 from an already-computed FRACTION.
*
* ADR-3180 Decision 7 (#3180): the completion-RATIO derivation has exactly one
* owner, and this is its kernel — the single place the `fraction -> integer
* percent` rounding and the 100 ceiling are expressed. `clampPercent` below is
* the count-shaped entry point and delegates here; a caller that already holds a
* fraction (rather than a completed/total pair) calls this directly instead of
* re-deriving `Math.min(100, Math.round(f * 100))` locally.
*
* Enforced by `scripts/lint-completion-ratio-drift.cjs`.
*/
export function clampPercentFromFraction(fraction: number): number {
return Math.min(100, Math.round(fraction * 100));
}
/** /**
* Compute progress percent clamped to 100. * Compute progress percent clamped to 100.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation. * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
*
* A non-positive (or absent) denominator yields `0` — "nothing to complete" is
* reported as 0%, never as 100%. Every `.planning/` completion percentage in this
* codebase routes through here (ADR-3180 Decision 7); the `total > 0 ? ... : 0`
* ternary that used to precede each inline copy IS this function's first line.
*/ */
export function clampPercent(completed: number, total: number): number { export function clampPercent(completed: number, total: number): number {
if (!total || total <= 0) return 0; if (!total || total <= 0) return 0;
return Math.min(100, Math.round((completed / total) * 100)); return clampPercentFromFraction(completed / total);
} }

View File

@@ -23,6 +23,7 @@ import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone } = roadmapParserModule; const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs'; import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs'; import { updateTableCell } from './markdown-table.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
import { platformWriteSync } from './shell-command-projection.cjs'; import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs'); import planningWorkspace = require('./planning-workspace.cjs');
@@ -491,7 +492,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
completed_phases: completedPhases, completed_phases: completedPhases,
total_plans: totalPlans, total_plans: totalPlans,
total_summaries: totalSummaries, total_summaries: totalSummaries,
progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0, progress_percent: clampPercent(totalSummaries, totalPlans),
current_phase: currentPhase ? currentPhase.number : null, current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null, next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null, missing_phase_details: missingDetails.length > 0 ? missingDetails : null,

View File

@@ -8,6 +8,7 @@
*/ */
import { splitTableRow } from './markdown-table.cjs'; import { splitTableRow } from './markdown-table.cjs';
import { clampPercentFromFraction } from './phase-lifecycle.cjs';
// Internal helpers // Internal helpers
function escapeRegex(str: string): string { function escapeRegex(str: string): string {
@@ -305,7 +306,7 @@ export function computeProgressPercent(
// cannot track through intermediate boolean variables). // cannot track through intermediate boolean variables).
const planFraction = hasPlanData ? (completedPlans ?? 0) / (totalPlans ?? 1) : 1; const planFraction = hasPlanData ? (completedPlans ?? 0) / (totalPlans ?? 1) : 1;
const phaseFraction = hasPhaseData ? (completedPhases ?? 0) / (totalPhases ?? 1) : 1; const phaseFraction = hasPhaseData ? (completedPhases ?? 0) / (totalPhases ?? 1) : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100)); return clampPercentFromFraction(Math.min(planFraction, phaseFraction));
} }
export function shouldPreserveExistingProgress(existingProgress: unknown, derivedProgress: unknown): boolean { export function shouldPreserveExistingProgress(existingProgress: unknown, derivedProgress: unknown): boolean {

View File

@@ -57,6 +57,7 @@ import { tokenizeHeadings, collectSection, replaceSection } from './markdown-sec
import type { HeadingToken } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs';
import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs'; import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs';
import { textEncodingError } from './validate.cjs'; import { textEncodingError } from './validate.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -766,7 +767,7 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
} }
} }
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0; const percent = clampPercent(totalSummaries, totalPlans);
const barWidth = 10; const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth); const filled = Math.round(percent / 100 * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);

View File

@@ -9,6 +9,7 @@
*/ */
import path from 'node:path'; import path from 'node:path';
import { clampPercent } from './phase-lifecycle.cjs';
// Internal helpers // Internal helpers
function toPosixPath(p: string): string { function toPosixPath(p: string): string {
@@ -425,13 +426,10 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs)
roadmap_phase_count: effectivePhaseCount, roadmap_phase_count: effectivePhaseCount,
total_plans: totalPlans, total_plans: totalPlans,
completed_plans: completedPlans, completed_plans: completedPlans,
// The `Math.min` cap is unreachable under milestone scoping (the invariant // `clampPercent`'s 100 ceiling is unreachable under milestone scoping (the
// above throws first) and survives only for the legacy unscoped path, where // invariant above throws first) and matters only for the legacy unscoped
// the denominator is a roadmap heading count that a caller cannot guarantee // path, where the denominator is a roadmap heading count that a caller
// bounds the numerator. // cannot guarantee bounds the numerator.
progress_percent: progress_percent: clampPercent(completedPhases, effectivePhaseCount),
effectivePhaseCount > 0
? Math.min(100, Math.round((completedPhases / effectivePhaseCount) * 100))
: 0,
}; };
} }

View File

@@ -891,10 +891,16 @@ const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// Exported so the suite-token resolution contract below can be asserted
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs'); // in-process rather than through a timed subprocess spawn (see evidence
// comment above describe('bug #641 ...')).
const {
walkTestFiles,
selectExplicitFiles,
selectFiles,
parseArgs,
} = require('../scripts/run-tests.cjs');
const PASS_BODY = `'use strict'; const PASS_BODY = `'use strict';
const { test } = require('node:test'); const { test } = require('node:test');
@@ -907,17 +913,42 @@ function seed(dir, names) {
} }
} }
function runHarness(testDir, args = [], extraEnv = {}) { // Mirrors scripts/run-tests.cjs main()'s selection path (parseArgs ->
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv }; // walkTestFiles -> selectExplicitFiles/selectFiles) exactly, called
delete env.NODE_TEST_CONTEXT; // in-process instead of through a spawned child that then spawns a nested
const result = runNode([HARNESS, ...args], { // `node --test`. See the evidence comment above describe('bug #641 ...').
cwd: path.join(__dirname, '..'), function selectInProcess(testDir, args) {
env, const parsed = parseArgs(args);
timeoutMs: PROBE_TIMEOUT_MS, if (parsed.error) return parsed;
}); const allFiles = walkTestFiles(testDir, '').sort();
return toLegacyResult(result); const usingExplicitFiles = parsed.files !== null || parsed.filesFrom !== null;
if (usingExplicitFiles) {
return selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom);
}
return { files: selectFiles(allFiles, parsed.suite) };
} }
// Redesign evidence (2026-08-08): these probes originally spawned
// scripts/run-tests.cjs as a real child — which itself spawns a NESTED
// `node --test` — under a fixed PROBE_TIMEOUT_MS=15000 wall-clock budget,
// concurrently with the ~31k-test full suite running in the same container.
// On the remote runner this produced intermittent failures shaped
// `null !== 0` (r.status === null: the child was KILLED at the timeout),
// never a failed assertion about suite-token resolution. Reproduced on
// `next` alone (sha e705652ba): 5 failures on linux-node22, 0 failures on
// linux-node24. The victim subset varied by run and by lane, and the
// failure count went DOWN (7 -> 4 unique failures) as an unrelated diff got
// heavier — a resource collision, not a flake. The subject under test is
// suite-TOKEN RESOLUTION (parseArgs / selectExplicitFiles / selectFiles),
// not test execution; running the seeded trivial fixture files was
// incidental and was the entire timeout surface. These probes now call the
// exported selection functions in-process — removing the wall-clock budget
// around a nested spawn, not raising its number. Real end-to-end coverage of
// run-tests.cjs spawning and running to completion (exit 0 from a real
// harness run) already exists in tests/run-tests-harness.test.cjs (e.g. 'no
// flag runs ALL test files', '--suite unit excludes marked suites',
// 'non-zero from node:test propagates through harness'), so no execution
// coverage is lost by converting the "(tests run successfully)" probe below.
describe('bug #641 — --files-from with bare suite token', () => { describe('bug #641 — --files-from with bare suite token', () => {
let tmpDir; let tmpDir;
@@ -935,57 +966,45 @@ describe('bug #641 — --files-from with bare suite token', () => {
const listPath = path.join(tmpDir, 'ci-selected-tests.txt'); const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8'); fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]); const r = selectInProcess(tmpDir, ['--files-from', listPath]);
// Must NOT exit 2 with the "not found" error. // Must NOT be the "not found" error shape (the old exit-2 crash).
assert.notStrictEqual( assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
r.status, // The unit suite file (a.test.cjs) must appear in the resolution.
2,
`Expected exit 0 or 1, got 2.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
assert.doesNotMatch(
r.stderr,
/requested test file\(s\) not found: unit/,
`Must not emit "not found: unit".\nstderr: ${r.stderr}`,
);
// The unit suite file (a.test.cjs) must appear in the run.
assert.ok( assert.ok(
r.stderr.includes('a.test.cjs'), r.files.includes('a.test.cjs'),
`Expected a.test.cjs (unit suite) to be selected.\nstderr: ${r.stderr}`, `Expected a.test.cjs (unit suite) to be selected. Resolved: ${r.files}`,
); );
// The security suite file must NOT be included (unit token = unit only). // The security suite file must NOT be included (unit token = unit only).
assert.ok( assert.ok(
!r.stderr.includes('b.security.test.cjs'), !r.files.includes('b.security.test.cjs'),
`Expected b.security.test.cjs (security suite) to be excluded.\nstderr: ${r.stderr}`, `Expected b.security.test.cjs (security suite) to be excluded. Resolved: ${r.files}`,
); );
}); });
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => { test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
// "Exits 0" is determined entirely by selection succeeding with a
// non-empty list — the seeded fixture is a trivial no-op, so executing
// it contributes nothing this in-process call doesn't already prove.
// End-to-end execution coverage of run-tests.cjs lives in
// tests/run-tests-harness.test.cjs (see the evidence comment above).
seed(tmpDir, ['a.test.cjs']); seed(tmpDir, ['a.test.cjs']);
const listPath = path.join(tmpDir, 'ci-selected-tests.txt'); const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8'); fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]); const r = selectInProcess(tmpDir, ['--files-from', listPath]);
assert.strictEqual( assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
r.status, assert.deepStrictEqual(r.files, ['a.test.cjs']);
0,
`Expected exit 0.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
);
}); });
test('--files with bare "unit" token also resolves correctly', () => { test('--files with bare "unit" token also resolves correctly', () => {
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']); seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const r = runHarness(tmpDir, ['--files', 'unit']); const r = selectInProcess(tmpDir, ['--files', 'unit']);
assert.notStrictEqual( assert.strictEqual(r.error, undefined, `Expected exit 0, got error: ${r.error}`);
r.status, assert.ok(r.files.includes('a.test.cjs'), `a.test.cjs must be selected. Resolved: ${r.files}`);
2, assert.ok(!r.files.includes('b.security.test.cjs'), `security file must not be selected. Resolved: ${r.files}`);
`Expected exit 0, got 2.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /requested test file\(s\) not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
assert.ok(!r.stderr.includes('b.security.test.cjs'), `security file must not be selected.\nstderr: ${r.stderr}`);
}); });
test('mixed: suite token "unit" alongside an explicit file resolves both', () => { test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
@@ -994,13 +1013,14 @@ describe('bug #641 — --files-from with bare suite token', () => {
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too. // 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8'); fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]); const r = selectInProcess(tmpDir, ['--files-from', listPath]);
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
// Both unit files present; security not. // Both unit files present exactly once (the union dedupes them); security not.
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`); assert.ok(r.files.includes('a.test.cjs'), `a.test.cjs must be selected. Resolved: ${r.files}`);
assert.ok(r.stderr.includes('b.test.cjs'), `b.test.cjs must be selected.\nstderr: ${r.stderr}`); assert.ok(r.files.includes('b.test.cjs'), `b.test.cjs must be selected. Resolved: ${r.files}`);
assert.ok(!r.stderr.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded.\nstderr: ${r.stderr}`); assert.ok(!r.files.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded. Resolved: ${r.files}`);
assert.strictEqual(r.files.length, 2, `expected no duplicate b.test.cjs. Resolved: ${r.files}`);
}); });
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => { test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
@@ -1013,15 +1033,10 @@ describe('bug #641 — --files-from with bare suite token', () => {
const listPath = path.join(tmpDir, '.ci-selected-tests.txt'); const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
fs.writeFileSync(listPath, 'unit\n', 'utf8'); fs.writeFileSync(listPath, 'unit\n', 'utf8');
const r = runHarness(tmpDir, ['--files-from', listPath]); const r = selectInProcess(tmpDir, ['--files-from', listPath]);
assert.strictEqual( assert.strictEqual(r.error, undefined, `#408 fallback: expected a resolved file list, got error: ${r.error}`);
r.status, assert.ok(r.files.includes('a.test.cjs'), `unit test must resolve. Resolved: ${r.files}`);
0,
`#408 fallback: expected exit 0 but got ${r.status}.\nstderr: ${r.stderr}`,
);
assert.doesNotMatch(r.stderr, /not found: unit/);
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
}); });
}); });

View File

@@ -0,0 +1,576 @@
/**
* Tests for the completion-RATIO single-owner drift guard (epic #3180,
* ADR-3180) — `scripts/lint-completion-ratio-drift.cjs`.
*
* Covers:
* - `findCompletionRatioDrift` — the per-line detection shape (Math.round-
* family + `* 100` scale + an EARLIER division on the same line), and
* its documented near-miss exclusions.
* - Function-scoped owner exemption: only `clampPercent` /
* `clampPercentFromFraction` inside `src/phase-lifecycle.cts` are
* exempt — an unrelated top-level function in that SAME file is not.
* - `scanRepo` against the real repo tree: zero unsanctioned
* re-derivations (the guard's actual contract).
* - The canonical owner itself (`gsd-core/bin/lib/phase-lifecycle.cjs`'s
* `clampPercent`/`clampPercentFromFraction`) at its numeric boundaries.
*
* Uses fs.mkdtempSync directly (matching plan-count-single-owner.test.cjs /
* milestone-window-single-owner.test.cjs's own drift-guard sections, which
* build ad hoc fixture trees rather than routing through
* tests/helpers.cjs's createTempDir/cleanup for this particular shape) —
* cleaned up in `t.after()`, never a fixed path.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const drift = require('../scripts/lint-completion-ratio-drift.cjs');
const { clampPercent, clampPercentFromFraction } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
const { scanPhasePlans } = require('../gsd-core/bin/lib/plan-scan.cjs');
const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const OWNER_RELPATH = path.join('src', 'phase-lifecycle.cts');
// ─── Fixture helpers (mirrors milestone-window-single-owner.test.cjs) ─────
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function writeState(cwd, fields) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const lines = ['---'];
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
lines.push('---', '');
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n'));
}
function writeFile(cwd, relPath, content) {
const full = path.join(cwd, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
// ─── POSITIVE: the Math.round family, each with a genuine completed/total ─
// division whose result is scaled by 100 AFTER the divide.
describe('findCompletionRatioDrift — positive detection across the rounding family', () => {
test('Math.round with a Math.min(100, ...) ceiling is detected', () => {
const line = 'const p = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].line, 1);
});
test('Math.floor variant is detected', () => {
const line = 'const p = Math.floor((done / total) * 100);';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.strictEqual(out.length, 1);
});
test('Math.ceil variant is detected', () => {
const line = 'const p = Math.ceil((done / total) * 100);';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.strictEqual(out.length, 1);
});
test('Math.trunc variant is detected', () => {
const line = 'const p = Math.trunc((done / total) * 100);';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.strictEqual(out.length, 1);
});
test('a version with no Math.min(100, ...) ceiling is still detected', () => {
const line = 'const p = total > 0 ? Math.round((done / total) * 100) : 0;';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.strictEqual(out.length, 1);
});
});
// ─── NEGATIVE: each near-miss, with a comment saying WHY it must not fire ─
describe('findCompletionRatioDrift — negative: documented near-misses', () => {
test('Math.round(n * 100) / 100 (2-decimal rounding) is NOT detected', () => {
// Scales FIRST, divides SECOND — clause (c)'s ordering requirement
// (divIdx < scaleIdx) is the guard's whole precision, and this idiom is
// the exact shape it exists to let through: it rounds an
// already-fractional value to 2 decimal places, unrelated to a
// completed/total percentage derivation.
const line = 'const rounded = Math.round(n * 100) / 100;';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.deepStrictEqual(out, []);
});
test('Math.floor(Math.random() * 100) is NOT detected', () => {
// Carries the rounding call and the *100 scale but no division anywhere
// on the line (DIVISION_RE finds nothing, divIdx === -1) — clause (c)
// alone excludes it.
const line = 'const p = Math.floor(Math.random() * 100);';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.deepStrictEqual(out, []);
});
test('Math.min(Math.round(ratio * 100), 100) is NOT detected', () => {
// Scales an ALREADY-COMPUTED fraction (`ratio`) — there is no division
// anywhere on this line either, so it is out of scope by domain (no
// completed/total pair is being re-derived here), the same reason the
// Math.random() case above is excluded.
const line = 'const p = Math.min(Math.round(ratio * 100), 100);';
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
assert.deepStrictEqual(out, []);
});
test('a division and a * 100 scale on two DIFFERENT lines is NOT detected', () => {
// Documented per-line limit: this guard's detection window is ONE
// source line. The division happens on line 1; line 2 carries the
// Math.round-family call and the *100 scale but no division of its
// own, so DIVISION_RE finds nothing on line 2 and clause (c) excludes
// it, even though the two lines together form the exact re-derivation
// shape the guard exists to catch.
const text = [
'const frac = done / total;',
'const p = Math.round(frac * 100);',
].join('\n');
const out = drift.findCompletionRatioDrift(text, 'src/somewhere.cts');
assert.deepStrictEqual(out, []);
});
});
// ─── OWNER SCOPING: function-scoped, not file-scoped ──────────────────────
describe('findCompletionRatioDrift — owner exemption is function-scoped, not file-scoped', () => {
test('the re-derivation line inside clampPercent in src/phase-lifecycle.cts is exempt', () => {
const text = [
'function clampPercent(completed, total) {',
' return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;',
'}',
].join('\n');
const out = drift.findCompletionRatioDrift(text, OWNER_RELPATH);
assert.deepStrictEqual(out, []);
});
test('the re-derivation line inside clampPercentFromFraction in src/phase-lifecycle.cts is exempt', () => {
const text = [
'function clampPercentFromFraction(fraction) {',
' return Math.min(100, Math.round((fraction * total) / 100 * 100));',
'}',
].join('\n');
// Note: the real clampPercentFromFraction body carries no division at
// all (Math.min(100, Math.round(fraction * 100))) and so never matches
// regardless of exemption — this fixture synthesizes a line that WOULD
// match the detection shape, specifically to prove the exemption itself
// (not merely the absence of a division) is what suppresses it.
const out = drift.findCompletionRatioDrift(text, OWNER_RELPATH);
assert.deepStrictEqual(out, []);
});
test('the SAME line inside a differently-named top-level function in the SAME file IS reported', () => {
// This is the point of function-scoped rather than file-scoped
// exemption: src/phase-lifecycle.cts is scanned like every other file,
// and only the two named canonical functions are exempt.
const text = [
'function someOtherFunction(completed, total) {',
' return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;',
'}',
].join('\n');
const out = drift.findCompletionRatioDrift(text, OWNER_RELPATH);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].line, 2);
});
test('exempt and non-exempt functions in ONE file: only the non-exempt line is reported', () => {
const text = [
'function clampPercent(completed, total) {',
' return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;',
'}',
'',
'function someOtherFunction(completed, total) {',
' return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;',
'}',
].join('\n');
const out = drift.findCompletionRatioDrift(text, OWNER_RELPATH);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].line, 6);
});
test('the same line in a DIFFERENT, non-owner file is reported (no exemption applies)', () => {
const line = 'const p = total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;';
const out = drift.findCompletionRatioDrift(line, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
});
});
// ─── MAX_REGEX_LITERAL_LEN boundary on the reported fragment ──────────────
// Sibling precedent: tests/plan-count-single-owner.test.cjs's
// `readRegexLiteralAt` MAX_REGEX_LITERAL_LEN boundary test (limit-1 reads,
// limit reads, limit+1 returns/truncates). This guard has no regex-literal
// tokenizer of its own (see module header) — it truncates its REPORTED
// fragment with `.trim().slice(0, MAX_REGEX_LITERAL_LEN)`, so the boundary
// here is on `found.length`, not on a returned `null`.
describe('findCompletionRatioDrift — MAX_REGEX_LITERAL_LEN boundary on the reported fragment', () => {
test('limit-1 reports the fragment in full, limit reports it in full, limit+1 truncates', () => {
const MAX = drift.MAX_REGEX_LITERAL_LEN;
assert.ok(Number.isInteger(MAX) && MAX > 10, `MAX_REGEX_LITERAL_LEN must be exported as an integer > 10, got ${MAX}`);
// A genuine re-derivation shape (Math.round + earlier division + *100),
// padded with trailing filler characters (never `/`, `*`, digits, or
// whitespace-adjacent tokens that could themselves alter detection) so
// the TOTAL trimmed-line length lands exactly on each boundary.
const base = 'const p = Math.round((done / total) * 100);';
const build = (totalLen) => {
if (totalLen < base.length) throw new Error(`totalLen ${totalLen} shorter than base fixture ${base.length}`);
return base + 'z'.repeat(totalLen - base.length);
};
const underBound = build(MAX - 1); // limit-1
const atBound = build(MAX); // limit
const overBound = build(MAX + 1); // limit+1
const underOut = drift.findCompletionRatioDrift(underBound, 'src/somewhere.cts');
assert.strictEqual(underOut.length, 1);
assert.strictEqual(underOut[0].found.length, MAX - 1);
assert.strictEqual(underOut[0].found, underBound);
const atOut = drift.findCompletionRatioDrift(atBound, 'src/somewhere.cts');
assert.strictEqual(atOut.length, 1);
assert.strictEqual(atOut[0].found.length, MAX);
assert.strictEqual(atOut[0].found, atBound);
const overOut = drift.findCompletionRatioDrift(overBound, 'src/somewhere.cts');
assert.strictEqual(overOut.length, 1);
assert.strictEqual(overOut[0].found.length, MAX);
// Truncated to exactly the limit-length fragment — the first MAX
// characters of overBound are byte-identical to atBound (same base,
// same padding character).
assert.strictEqual(overOut[0].found, atBound);
});
});
// ─── scanRepo — tree-walk mechanics on a synthetic tree ───────────────────
describe('scanRepo — synthetic tree', () => {
test('a violation in a fresh temp tree is reported with its file and line', (t) => {
const root = createTempDir('gsd-completion-ratio-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(
path.join(root, 'src', 'fake.cts'),
'const p = Math.round((done / total) * 100);\n',
);
const violations = drift.scanRepo(root);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].file, path.join('src', 'fake.cts'));
assert.strictEqual(violations[0].line, 1);
});
test('a clean temp tree with no re-derivations reports zero violations', (t) => {
const root = createTempDir('gsd-completion-ratio-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'clean.cts'), 'const x = 1;\n');
const violations = drift.scanRepo(root);
assert.deepStrictEqual(violations, []);
});
});
// ─── WHOLE-REPO contract: the guard's actual promise ──────────────────────
test('scanRepo(repoRoot) against the real repo returns EMPTY — zero independent re-derivations', () => {
// This is the guard's actual contract ("0 independent re-derivations") —
// the test that fails the day copy number seven lands.
const violations = drift.scanRepo(REPO_ROOT);
assert.deepStrictEqual(violations, []);
});
// ─── Canonical owner: clampPercent / clampPercentFromFraction boundaries ──
describe('clampPercent — canonical owner boundaries', () => {
test('total 0 yields 0 (nothing to complete is 0%, never 100%)', () => {
assert.strictEqual(clampPercent(0, 0), 0);
});
test('total 1, completed 0 yields 0', () => {
assert.strictEqual(clampPercent(0, 1), 0);
});
test('completed === total yields 100', () => {
assert.strictEqual(clampPercent(5, 5), 100);
});
test('completed > total: ceiling holds at 100', () => {
assert.strictEqual(clampPercent(7, 5), 100);
});
test('a negative total yields 0', () => {
assert.strictEqual(clampPercent(3, -5), 0);
});
});
describe('clampPercentFromFraction — canonical owner boundaries', () => {
test('fraction 0 yields 0', () => {
assert.strictEqual(clampPercentFromFraction(0), 0);
});
test('fraction 1 (completed === total, expressed as a fraction) yields 100', () => {
assert.strictEqual(clampPercentFromFraction(1), 100);
});
test('a fraction greater than 1 (completed > total): ceiling holds at 100', () => {
assert.strictEqual(clampPercentFromFraction(1.4), 100);
});
});
// ═════════════════════════════════════════════════════════════════════════
// CONSUMER identity (ADR-3180 Decision 4c): the completion-ratio derivation's
// canonical owner is `clampPercent`/`clampPercentFromFraction`
// (src/phase-lifecycle.cts). Decision 4(c) requires the identity test to
// compare each CONSUMER's own observable output against the canonical
// owner's result for the SAME input — a consumer can call the owner and
// then post-process the counts it feeds it locally, which satisfies both
// the structural lint (no re-implementation of the rounding arithmetic) AND
// an owner-level identity test (the owner itself is untouched) while
// silently restoring divergence. tests/milestone-window-single-owner.test.cjs
// (~lines 819-857) is the correct precedent in this repo: it drives real CLI
// verbs and asserts on THEIR output, not the owner's return value alone.
//
// The fixture below deliberately includes a `status: superseded` plan
// (03-baz/03-02-PLAN.md) — the canonical live-plan-counting owner
// (`scanPhasePlans`, #3183) excludes it, but a consumer that locally
// re-filtered or hand-counted files instead of calling the owner would
// include it, producing a DIFFERENT numerator/denominator and therefore a
// DIFFERENT percentage. That divergence is what gives this identity test
// power per Decision 4(c) — the "power proof" test below shows the two
// counting strategies land on different percentages (75% vs 60%) over the
// SAME fixture.
// ═════════════════════════════════════════════════════════════════════════
describe('CONSUMER identity (ADR-3180 Decision 4c): percent fields match the canonical owner', () => {
function buildFixture(cwd) {
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, [
'## v1.0 Current 🚧',
'',
'### Phase 1: Foo',
'',
'### Phase 2: Bar',
'',
'### Phase 3: Baz',
].join('\n'));
// Phase 1 (01-foo): 2 live plans, 2 summaries, verified -> Complete.
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
writeFile(cwd, '.planning/phases/01-foo/01-02-PLAN.md', '# Plan\n');
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
writeFile(cwd, '.planning/phases/01-foo/01-02-SUMMARY.md', '# Summary\n');
writeFile(cwd, '.planning/phases/01-foo/VERIFICATION.md', '---\nstatus: passed\n---\n# Verification\n');
// Phase 2 (02-bar): 1 live plan, 0 summaries -> Planned, not complete.
writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n');
// Phase 3 (03-baz): 1 live plan + 1 SUPERSEDED plan (excluded by the
// canonical owner scanPhasePlans, #2349), 1 summary, verified -> Complete.
writeFile(cwd, '.planning/phases/03-baz/03-01-PLAN.md', '# Plan\n');
writeFile(cwd, '.planning/phases/03-baz/03-01-SUMMARY.md', '# Summary\n');
writeFile(cwd, '.planning/phases/03-baz/03-02-PLAN.md', '---\nstatus: superseded\n---\n# Plan\n');
writeFile(cwd, '.planning/phases/03-baz/VERIFICATION.md', '---\nstatus: passed\n---\n# Verification\n');
return ['01-foo', '02-bar', '03-baz'];
}
// Independently compute the canonical totals by calling the OWNER
// (scanPhasePlans) directly per phase directory — never re-deriving the
// superseded-exclusion filter locally in this test.
function ownerTotals(cwd, dirs) {
let totalPlans = 0;
let totalSummaries = 0;
for (const dir of dirs) {
const scan = scanPhasePlans(path.join(cwd, '.planning', 'phases', dir));
totalPlans += scan.planCount;
totalSummaries += scan.summaryCount;
}
return { totalPlans, totalSummaries };
}
test('power proof: a naive raw-file count over this fixture computes a DIFFERENT percentage than the canonical owner', () => {
// Not itself a guard assertion — documents why the fixture below has the
// power Decision 4(c) requires. If this ever fails, the fixture has
// stopped exercising the superseded-exclusion divergence and must be
// redesigned.
const canonical = clampPercent(3, 4); // scanPhasePlans-derived: 3 summaries / 4 live plans
const naive = clampPercent(3, 5); // raw file count: 03-02-PLAN.md counted despite being superseded
assert.strictEqual(canonical, 75);
assert.strictEqual(naive, 60);
assert.notStrictEqual(canonical, naive);
});
test('roadmap analyze: progress_percent matches clampPercent(owner totals)', (t) => {
const cwd = createTempDir('gsd-completion-ratio-consumer-');
t.after(() => cleanup(cwd));
const dirs = buildFixture(cwd);
const { totalPlans, totalSummaries } = ownerTotals(cwd, dirs);
const expected = clampPercent(totalSummaries, totalPlans);
assert.strictEqual(expected, 75, 'fixture sanity: must match the power-proof test above');
const result = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
assert.strictEqual(result.success, true, result.error);
const analyzed = JSON.parse(result.output);
assert.strictEqual(analyzed.progress_percent, expected);
});
test('query progress: the percent field matches clampPercent(owner totals)', (t) => {
const cwd = createTempDir('gsd-completion-ratio-consumer-');
t.after(() => cleanup(cwd));
const dirs = buildFixture(cwd);
const { totalPlans, totalSummaries } = ownerTotals(cwd, dirs);
const expected = clampPercent(totalSummaries, totalPlans);
assert.strictEqual(expected, 75, 'fixture sanity: must match the power-proof test above');
const result = runGsdTools(['query', 'progress', '--cwd', cwd, '--raw'], cwd);
assert.strictEqual(result.success, true, result.error);
const rendered = JSON.parse(result.output);
assert.strictEqual(rendered.percent, expected);
});
test('stats: plan_percent matches clampPercent(owner totals); percent (phase-level) matches clampPercent of its own reported completed/total', (t) => {
const cwd = createTempDir('gsd-completion-ratio-consumer-');
t.after(() => cleanup(cwd));
const dirs = buildFixture(cwd);
const { totalPlans, totalSummaries } = ownerTotals(cwd, dirs);
const expectedPlanPercent = clampPercent(totalSummaries, totalPlans);
assert.strictEqual(expectedPlanPercent, 75, 'fixture sanity: must match the power-proof test above');
const result = runGsdTools(['stats', '--cwd', cwd, '--raw'], cwd);
assert.strictEqual(result.success, true, result.error);
const stats = JSON.parse(result.output);
// plan_percent: the completion-ratio derivation this ADR section (§7.6)
// owns — checked against the INDEPENDENTLY owner-derived totals, so a
// consumer post-filtering scanPhasePlans's output locally would fail
// this exact assertion (see the power-proof test above).
assert.strictEqual(stats.plan_percent, expectedPlanPercent);
// percent (phase-level completion): the completed/total PAIR itself is
// phase enumeration + phase completion's derivation (§7.3/§7.4 — a
// SEPARATE ADR-3180 phase, not owned by this guard; §7.6's own status
// note says numerator/denominator SAME-SCOPE-SET enforcement is Phase 7,
// not yet shipped). Re-deriving `determinePhaseStatus` locally in this
// test to independently compute completedPhases/phasesTotal would
// duplicate PRODUCTION status logic rather than exercise this
// derivation's owner. What THIS guard (§7.6, completion-ratio
// arithmetic) owns is that whatever completed/total pair `stats`
// reports is turned into a percent through the canonical clampPercent
// arithmetic, never a locally re-derived ternary/rounding — asserted
// directly against the command's own reported pair.
assert.strictEqual(stats.percent, clampPercent(stats.phases_completed, stats.phases_total));
// Fixture sanity: phases_completed/phases_total must be the values this
// fixture was designed to produce (2 of 3 phases Complete), so the
// assertion above is not vacuously true against a degenerate 0/0 pair.
assert.strictEqual(stats.phases_completed, 2);
assert.strictEqual(stats.phases_total, 3);
assert.strictEqual(stats.percent, 67);
});
});
// ═════════════════════════════════════════════════════════════════════════
// PROPERTY tests (CONTRIBUTING.md: parsers, budget limits, and bijective
// contracts require at least one fast-check property test). clampPercent /
// clampPercentFromFraction are clamp/budget-limit functions.
// ═════════════════════════════════════════════════════════════════════════
describe('clampPercent / clampPercentFromFraction — property tests (fast-check)', () => {
// Bounded integer domains (matching this repo's fast-check convention —
// see tests/derive-progress.property.test.cjs, tests/eval.property.test.cjs
// — rather than unconstrained fc.float()/fc.double(), which would exercise
// float-precision edge cases orthogonal to the property under test).
test('property: non-negative completed + any finite total -> integer result in [0, 100]', () => {
// Restricted to completed >= 0 -- every real caller in this codebase
// passes a COUNT (never a negative numerator), and clampPercent does not
// claim to clamp a negative numerator's result into [0, 100] (verified
// against the implementation: clampPercent(-5, 10) returns -50, since
// Math.min(100, x) has no LOWER bound). The property as stated is true
// only within the domain the function is actually used in.
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: -1_000_000, max: 1_000_000 }),
(completed, total) => {
const result = clampPercent(completed, total);
assert.ok(Number.isInteger(result), `result must be an integer, got ${result}`);
assert.ok(result >= 0 && result <= 100, `result must be in [0, 100], got ${result} for clampPercent(${completed}, ${total})`);
},
),
);
});
test('property: a non-positive or non-finite total always yields exactly 0 (for a non-negative completed)', () => {
// completed restricted to >= 0 for the same reason as the property
// above (a real numerator is always a count) — AND to sidestep a signed
// -0 vs +0 distinction: with total === Infinity the division short-
// circuit is skipped (Infinity > 0), so completed/Infinity is computed
// directly, and a NEGATIVE completed produces -0 (verified against the
// implementation: clampPercent(-5, Infinity) returns -0, which
// Object.is-based assert.strictEqual treats as distinct from +0 even
// though both represent 0%).
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.oneof(
fc.integer({ min: -1_000_000, max: 0 }),
fc.constant(NaN),
fc.constant(Infinity),
fc.constant(-Infinity),
),
(completed, total) => {
assert.strictEqual(clampPercent(completed, total), 0);
},
),
);
});
test('property: clampPercent(c, t) === clampPercentFromFraction(c / t) for every t > 0', () => {
fc.assert(
fc.property(
fc.integer({ min: -1_000_000, max: 1_000_000 }),
fc.integer({ min: 1, max: 1_000_000 }),
(completed, total) => {
assert.strictEqual(clampPercent(completed, total), clampPercentFromFraction(completed / total));
},
),
);
});
test('property: clampPercent is monotonic non-decreasing in completed for a fixed total > 0', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 1_000_000 }),
fc.integer({ min: -1_000_000, max: 1_000_000 }),
fc.integer({ min: -1_000_000, max: 1_000_000 }),
(total, a, b) => {
const [lo, hi] = a <= b ? [a, b] : [b, a];
assert.ok(
clampPercent(lo, total) <= clampPercent(hi, total),
`clampPercent(${lo}, ${total})=${clampPercent(lo, total)} must be <= clampPercent(${hi}, ${total})=${clampPercent(hi, total)}`,
);
},
),
);
});
});

View File

@@ -399,21 +399,20 @@ describe('parsePredicates: ID/value grammar boundaries (C)', () => {
assert.equal(r.predicates.length, 0, 'a doubled dot (empty segment) must be rejected, not silently accepted'); assert.equal(r.predicates.length, 0, 'a doubled dot (empty segment) must be rejected, not silently accepted');
}); });
test('idGrammarValidationIsLinearTimeAgainstManyConsecutiveDots', () => { test('idGrammarValidationRejectsManyConsecutiveDots', () => {
// DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the old // DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the old
// `ID_RE`'s `(?:\.[A-Za-z0-9_.-]+)*` group was exponential in the number // `ID_RE`'s `(?:\.[A-Za-z0-9_.-]+)*` group was exponential in the number
// of consecutive dots (measured: ~565ms for 40 dots). The structural // of consecutive dots (measured: ~565ms for 40 dots). The structural
// per-segment validator is linear. A generous wall-clock bound is used // per-segment validator is linear. No elapsed-time bound is asserted:
// only as a smoke check; the load-bearing assertion is that the result // the load-bearing assertion is that the result is a clean rejection
// is a clean rejection (an id-shaped line with 60 consecutive dots has // (an id-shaped line with 60 consecutive dots has an empty segment at
// an empty segment at every step and must not parse). // every step and must not parse). An exponential regression would not
// finish at all, not merely exceed a threshold — so a clean rejection
// is itself the discriminator.
const dots = '.'.repeat(60); const dots = '.'.repeat(60);
const md = `\`A${dots}x=value\``; const md = `\`A${dots}x=value\``;
const start = Date.now();
const r = parsePredicates(md); const r = parsePredicates(md);
const elapsedMs = Date.now() - start;
assert.equal(r.predicates.length, 0, 'an id with 60 consecutive dots has empty segments and must cleanly reject'); assert.equal(r.predicates.length, 0, 'an id with 60 consecutive dots has empty segments and must cleanly reject');
assert.ok(elapsedMs < 1000, `expected well under 1s (linear time), got ${elapsedMs}ms — possible ReDoS regression`);
}); });
}); });

View File

@@ -1119,6 +1119,42 @@ test('root confinement holds', { skip: process.platform === 'win32' ? 'symlink c
assert.strictEqual(violations.length, 0); assert.strictEqual(violations.length, 0);
}); });
// The guard's owner file (src/roadmap-parser.cts) is no longer exempt as a
// whole file — only its named canonical functions are (FUNCTION_SCOPED_EXEMPTIONS).
// These synthesize a relPath of 'src/roadmap-parser.cts' WITHOUT touching the
// real source file, exercising findMilestoneWindowDrift directly.
test('a non-exempt top-level function in src/roadmap-parser.cts IS reported', () => {
const relPath = path.join('src', 'roadmap-parser.cts');
const text = [
'function someOtherFunction() {',
` ${violatingLine().trim()}`,
'}',
].join('\n');
const violations = driftGuard.findMilestoneWindowDrift(text, relPath);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].line, 2);
});
const OWNER_FILE_EXEMPT_FUNCTIONS = [
'isMilestoneShippedInRoadmap',
'locateMilestoneHeadings',
'hasMilestoneSectioning',
'extractCurrentMilestoneScoped',
];
for (const fnName of OWNER_FILE_EXEMPT_FUNCTIONS) {
test(`owner-file exempt function ${fnName} in src/roadmap-parser.cts is NOT reported`, () => {
const relPath = path.join('src', 'roadmap-parser.cts');
const text = [
`function ${fnName}() {`,
` ${violatingLine().trim()}`,
'}',
].join('\n');
const violations = driftGuard.findMilestoneWindowDrift(text, relPath);
assert.deepStrictEqual(violations, []);
});
}
test('report output is sanitized', () => { test('report output is sanitized', () => {
// findMilestoneWindowDrift returns the RAW fragment; main() sanitizes both // findMilestoneWindowDrift returns the RAW fragment; main() sanitizes both
// `file` and `found` at the reporting boundary via the shared // `file` and `found` at the reporting boundary via the shared

View File

@@ -134,14 +134,17 @@ describe('normalizeTestCommand: security hardening (#1857 review)', () => {
}); });
} }
test('an oversized command is returned unchanged and in linear time (no ReDoS)', () => { test('an oversized command is returned unchanged (no ReDoS)', () => {
// The blow-up input from the review: a long "npm " run with no `test` token. // The blow-up input from the review: a long "npm " run with no `test` token.
const huge = 'npm '.repeat(200000); // ~800 KB const huge = 'npm '.repeat(200000); // ~800 KB
const start = Date.now();
const out = normalizeTestCommand(huge, '/tmp'); const out = normalizeTestCommand(huge, '/tmp');
const elapsedMs = Date.now() - start; // No elapsed-time bound: catastrophic backtracking on an 800 KB input
// does not take 251ms, it does not finish at all. A real ReDoS
// regression manifests as the suite being killed on this test, which is
// a louder and more reliable signal than a threshold — the threshold
// only ever distinguished "fast" from "slightly slow" (bench load), not
// correctness.
assert.strictEqual(out, huge, 'oversized input must be returned unchanged'); assert.strictEqual(out, huge, 'oversized input must be returned unchanged');
assert.ok(elapsedMs < 250, `normalization must be fast even on adversarial input (took ${elapsedMs}ms)`);
}); });
test('a package.json that is not a regular file is ignored (no FIFO hang)', () => { test('a package.json that is not a regular file is ignored (no FIFO hang)', () => {

View File

@@ -0,0 +1,326 @@
/**
* Tests for the prompt-layer plan/summary-COUNTING drift guard (epic #3180,
* ADR-3180 Decision 4(e)) — `scripts/lint-planning-prompt-drift.cjs`.
*
* Covers:
* - `findPromptDrift` — the per-line detection shape (a `*...PLAN.md` /
* `*...SUMMARY.md` set glob AND a counting operator on the same line),
* and its documented near-miss exclusions.
* - `diffAgainstBaseline` — the three ratchet invariants (known / fresh /
* stale), keyed on TEXT not line number, exercised on synthetic input.
* - `loadBaseline` / `scanRepo` against the real, committed repo state —
* the guard's actual contract.
*
* Uses fs.mkdtempSync directly for the one synthetic-tree fixture, matching
* the sibling drift-guard test suites' own drift-guard sections — cleaned
* up in `t.after()`, never a fixed path.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const drift = require('../scripts/lint-planning-prompt-drift.cjs');
const { findPromptDrift, scanRepo, loadBaseline, diffAgainstBaseline, toPosixRel, writeBaseline } = drift;
const { createTempDir, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
// ─── POSITIVE ───────────────────────────────────────────────────────────
describe('findPromptDrift — positive detection', () => {
test('X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l) is detected', () => {
const line = 'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-PLAN.md');
assert.strictEqual(out[0].text, line);
});
test('the *-SUMMARY.md variant is detected', () => {
const line = 'X=$(ls dir/*-SUMMARY.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-SUMMARY.md');
});
test('a grep -c variant is detected', () => {
const line = "Y=$(grep -cE '^' dir/*-PLAN.md)";
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-PLAN.md');
});
});
// ─── NEGATIVE — each with a comment saying WHY it must not fire ──────────
describe('findPromptDrift — negative: documented near-misses', () => {
test('grep -cE task-heading count inside ONE NAMED plan (no glob) is NOT detected', () => {
// A real line in gsd-core/workflows/execute-plan.md: it counts <task>
// elements INSIDE one already-named plan file — no `*` glob token
// anywhere near PLAN.md — so it is not a plan-COUNT re-derivation. A
// false positive here would redden lint:ci on an untouched file.
const line = "grep -cE '^\\s*<task[[:space:]>]' .planning/phases/[current-phase-dir]/{phase}-{plan}-PLAN.md";
const out = findPromptDrift(line, 'gsd-core/workflows/execute-plan.md');
assert.deepStrictEqual(out, []);
});
test('a *-UAT.md count is NOT detected', () => {
// UAT artifacts are a different derivation this guard does not own —
// PLAN_SUMMARY_GLOB_RE requires the literal PLAN.md or SUMMARY.md
// suffix, which "UAT.md" never satisfies.
const line = 'X=$(ls dir/*-UAT.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.deepStrictEqual(out, []);
});
test('a line that globs plan files but does not count them is NOT detected', () => {
// Reading/iterating (cat, backup, cross-reference) over a *-PLAN.md
// glob without a counting operator is not this derivation — every
// non-counting *-PLAN.md/*-SUMMARY.md glob in plan-phase.md is exactly
// this shape and is deliberately left alone.
const line = 'cat dir/*-PLAN.md';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.deepStrictEqual(out, []);
});
});
// ─── RATCHET MECHANICS — diffAgainstBaseline on synthetic inputs ─────────
describe('diffAgainstBaseline — ratchet invariants (synthetic)', () => {
test('a violation whose (file, text) pair is in the baseline is KNOWN: neither fresh nor stale', () => {
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a violation absent from the baseline is FRESH: fails', () => {
const baseline = [];
const violations = [
{ file: 'a.md', line: 1, found: '*-PLAN.md', text: 'Y=$(grep -c dir/*-PLAN.md)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].text, 'Y=$(grep -c dir/*-PLAN.md)');
assert.deepStrictEqual(stale, []);
});
test('a baseline entry matching nothing this run is STALE: fails', () => {
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].text, 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)');
});
test('keying is on TEXT not line number: the same trimmed text at a different line is still KNOWN', () => {
// This is what stops the baseline rotting on an unrelated edit that
// merely shifts line numbers (a new paragraph, a reworded step).
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [
{ file: 'a.md', line: 999, found: '*-PLAN.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
// ─── count-aware ratchet (Finding-3 fix): duplicate (file, text) pairs no
// longer make a partial migration invisible ───────────────────────────
test('a pair with count:2 fully matched by TWO occurrences is KNOWN: neither fresh nor stale', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 40, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a pair with count:2 but only ONE occurrence this run is a PARTIAL-migration STALE, naming both numbers', () => {
// This is the exact defect Finding 3 closes: migrating only ONE of two
// byte-identical sites must not be invisible to the ratchet just because
// the OTHER site still matches the (file, text) pair.
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].count, 2);
assert.strictEqual(stale[0].actualCount, 1);
});
test('a pair with count:2 and ZERO occurrences this run is fully STALE (both sites migrated)', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const { fresh, stale } = diffAgainstBaseline([], baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].actualCount, 0);
assert.strictEqual(stale[0].count, 2);
});
test('a pair with count:1 but a THIRD occurrence appears this run: the excess occurrence is FRESH (new copy)', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 1 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 55, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 55);
});
test('an entry with no `count` field defaults to acknowledging exactly ONE occurrence', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 55, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 55);
});
});
// ─── scanRepo — tree-walk mechanics on a synthetic tree ───────────────────
describe('scanRepo — synthetic tree', () => {
test('a violation in a fresh temp tree is reported with its file, line, and text', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(
path.join(root, 'gsd-core', 'workflows', 'fake.md'),
'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)\n',
);
const violations = scanRepo(root);
assert.strictEqual(violations.length, 1);
// Always POSIX-separated regardless of the host OS's native separator
// (`path.join` would build native separators here, which is exactly the
// Windows-vs-POSIX mismatch this guard's baseline keying must not have —
// see the Windows-shaped-path coverage below).
assert.strictEqual(violations[0].file, 'gsd-core/workflows/fake.md');
assert.strictEqual(violations[0].line, 1);
assert.strictEqual(violations[0].found, '*-PLAN.md');
});
test('a clean temp tree with no re-derivations reports zero violations', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-core', 'workflows', 'clean.md'), 'no globs or counts here\n');
const violations = scanRepo(root);
assert.deepStrictEqual(violations, []);
});
});
// ─── WINDOWS PATH-SEPARATOR NORMALIZATION — the #3223 regression ─────────
//
// `scanTree` (scripts/lib/drift-scan.cjs) builds its repo-relative path via
// `path.relative()`, which uses NATIVE separators. On Windows that is
// `gsd-core\workflows\progress.md`, while the committed baseline
// (`scripts/baselines/planning-prompt-drift-baseline.json`) stores POSIX
// paths — an un-normalized Windows path silently fails to match ANY
// baseline entry, so every violation reports FRESH and every baseline entry
// reports STALE (a 100% guard failure on Windows, caught by GitHub Actions'
// Windows CI lane on PR #3223; the Linux-only remote runner this repo
// otherwise gates on cannot see this class at all).
//
// This coverage drives the pure functions with a Windows-shaped path
// directly — no mocking of the filesystem and NOT gated on
// `process.platform` — so it fails identically on every OS pre-fix and
// passes identically on every OS post-fix. Skipping it on non-Windows would
// recreate the exact blind spot that let this ship.
describe('Windows-shaped repo-relative paths are normalized to POSIX', () => {
const WINDOWS_REL = 'gsd-core\\workflows\\progress.md';
const POSIX_REL = 'gsd-core/workflows/progress.md';
const WINDOWS_LINE = 'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)';
test('toPosixRel converts a Windows-shaped separator run to POSIX, and is a no-op on an already-POSIX path', () => {
assert.strictEqual(toPosixRel(WINDOWS_REL), POSIX_REL);
assert.strictEqual(toPosixRel(POSIX_REL), POSIX_REL);
});
test('findPromptDrift on a Windows-shaped relPath reports a POSIX `file`, regardless of input separator', () => {
const out = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].file, POSIX_REL);
assert.ok(!out[0].file.includes('\\'), 'reported file must carry no backslashes');
});
test('a violation produced from a Windows-shaped path matches a POSIX baseline entry: classified KNOWN, not fresh and not stale', () => {
const baseline = [{ file: POSIX_REL, text: WINDOWS_LINE }];
const violations = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a violation produced from a Windows-shaped path does NOT match if left un-normalized (sanity check the assertion above is meaningful)', () => {
// Same inputs as the previous test, but bypassing toPosixRel to prove the
// KNOWN classification above is actually exercising normalization, not a
// coincidence of the fixture.
const baseline = [{ file: POSIX_REL, text: WINDOWS_LINE }];
const violations = [{ file: WINDOWS_REL, line: 1, found: '*-PLAN.md', text: WINDOWS_LINE }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(stale.length, 1);
});
test('--update (writeBaseline) serializes a POSIX `file` for a Windows-shaped input', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-update-');
t.after(() => cleanup(root));
const violations = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
writeBaseline(root, violations);
const written = JSON.parse(fs.readFileSync(path.join(root, 'scripts', 'baselines', 'planning-prompt-drift-baseline.json'), 'utf8'));
assert.strictEqual(written.entries.length, 1);
assert.strictEqual(written.entries[0].file, POSIX_REL);
assert.ok(!written.entries[0].file.includes('\\'), 'written baseline entry must carry no backslashes');
});
});
// ─── BASELINE INTEGRITY — both directions, against the real repo ─────────
test('loadBaseline on the committed baseline returns exactly 6 entries (one row per distinct (file, text) pair)', () => {
// 7 total ACKNOWLEDGED occurrences across 6 distinct pairs: plan-phase.md's
// byte-identical DISK_PLANS site fires at two different lines and is
// recorded as ONE row carrying `count: 2` (the Finding-3 fix — a
// duplicated-row baseline made migrating only one of the two sites
// invisible to the ratchet).
const { entries, errors } = loadBaseline(REPO_ROOT);
assert.deepStrictEqual(errors, []);
assert.strictEqual(entries.length, 6);
const totalAcknowledgedOccurrences = entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
assert.strictEqual(totalAcknowledgedOccurrences, 7);
const planPhaseEntry = entries.find((e) => e.file === 'gsd-core/workflows/plan-phase.md');
assert.strictEqual(planPhaseEntry.count, 2);
});
test('scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale', () => {
// The guard's actual contract: every re-derivation this run finds is
// already acknowledged in the baseline, and every baseline entry still
// fires — no fresh, no stale, in either direction.
const violations = scanRepo(REPO_ROOT);
const { entries: baseline, errors } = loadBaseline(REPO_ROOT);
assert.deepStrictEqual(errors, []);
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});

View File

@@ -57,11 +57,11 @@ describe('#2351 run-with-timeout — exit-code contract', () => {
}); });
test('exits 124 when the wall-clock budget is exceeded (matches GNU timeout)', () => { test('exits 124 when the wall-clock budget is exceeded (matches GNU timeout)', () => {
const start = Date.now();
const r = runVerb(['1', '--', ...HANG]); const r = runVerb(['1', '--', ...HANG]);
// exit 124 is itself the discriminator: a harness backstop kill surfaces
// as status === null (signal), never as 124 — so no elapsed-time
// assertion is needed or allowed here.
assert.equal(r.status, 124, 'a timed-out command must exit 124'); assert.equal(r.status, 124, 'a timed-out command must exit 124');
// Sanity: the cap actually fired promptly, not the 30s harness backstop.
assert.ok(Date.now() - start < 15000, 'timeout should fire near the 1s budget');
}); });
test('exits 127 when the command is not found (matches GNU timeout)', () => { test('exits 127 when the command is not found (matches GNU timeout)', () => {