* 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:
5
.changeset/eager-lynx-romp.md
Normal file
5
.changeset/eager-lynx-romp.md
Normal 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)
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
47
scripts/baselines/planning-prompt-drift-baseline.json
Normal file
47
scripts/baselines/planning-prompt-drift-baseline.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
214
scripts/lint-completion-ratio-drift.cjs
Normal file
214
scripts/lint-completion-ratio-drift.cjs
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
434
scripts/lint-planning-prompt-drift.cjs
Normal file
434
scripts/lint-planning-prompt-drift.cjs
Normal 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} | ||||||