* 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:
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
|
||||
* equivalent need, since its own literal-bearing shape is regex-only).
|
||||
*
|
||||
* Owner file (exempt by construction): `src/roadmap-parser.cts` — it not only
|
||||
* DEFINES this grammar but composes `#{1,3}` with `(?!Phase...)`/marker
|
||||
* alternations at several internal call sites (`computeMilestoneSectionEnd`,
|
||||
* `locateMilestoneHeadings`, `extractCurrentMilestoneScoped`'s
|
||||
* `anyMilestonePattern`/`anyMilestoneOrDetails`) that are the canonical
|
||||
* implementation, not copies of it.
|
||||
* Owner file: `src/roadmap-parser.cts` DEFINES this grammar, but it is NOT
|
||||
* exempt as a whole file — that was the original design (a bare per-file
|
||||
* allowlist) and it closed off exactly the blind spot ADR-3180 Decision 4(a)
|
||||
* warns about: `getMilestoneInfo`, added later in this same owner file,
|
||||
* hand-rolled its own milestone-heading regex (issues #3171, #3197) and the
|
||||
* 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
|
||||
* 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
|
||||
// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption
|
||||
// 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([
|
||||
[path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])],
|
||||
[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
|
||||
@@ -287,10 +327,12 @@ function scanRepo(root) {
|
||||
scanExt: SCAN_EXT,
|
||||
onFile(rel, text) {
|
||||
// `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
|
||||
// consistently regardless of which symlink reached the file.
|
||||
if (rel === OWNER_FILE) return [];
|
||||
// consistently regardless of which symlink reached the file. The owner
|
||||
// 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 }));
|
||||
},
|
||||
});
|
||||
|
||||
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} | ||||