* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4 The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a ROADMAP checkbox is a human annotation with no machine authority. Section 7.4 still carried the OPEN QUESTION and was marked blocked, so the contract said one thing and the tracker another. Recorded per section 7's own rule - a behavior not stated there is not decided, and amending a rule is an ADR amendment rather than a code change with a comment. The decision comment names Phase 4's PR as the carrier of this edit and makes it an acceptance criterion that the text be in the tree before implementation begins, so this lands first, alone, ahead of any code. Also clears the stale blocked-on-2957 row in the guard roster. * refactor(#3186): one shared phase-completion predicate, disk-strict isPhaseComplete in verification.cts becomes the single owner. It calls readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init gated the read on a plan count and synthesized a not_required sentinel, so phase.complete succeeded while init.manager reported incomplete for the same phase. The guard, built and run before scope was fixed per Amendment 3, found 9 re-derivations where the ADR named 3. Four were unnamed, including one in the prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under disk-strict is the divergence itself. Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no machine authority. The overrides in roadmap analyze and init manager are deleted rather than generalized; the user's checkbox stays in ROADMAP.md, only its authority goes. scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded - they answer 'are all plans summarized', which is a different question, and folding them would either over-report completion or invert the dependency direction between Phase 1's owner and this one. Verified on the remote runner. * fix(#3186): close seven review findings and record the missing-verdict rule The isolated review reproduced a write-path regression I introduced: migrating cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase with a fresh passing verification plus a newly-added unsummarized plan reported complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The owner stays right per 7.4 - plan count is not a completion precondition - so the gate is restored at the write site as an explicit composition, mirroring the separate 2648 unexecuted-plan gate cmdPhaseComplete already carries. The spec axis was right that my 0.x-split reasoning was too permissive. The 2957 decision names buildStateFrontmatter as one of the three that must converge, and buildWorkstreamInventory combined a summaries-met local with verification data to decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in a third surface. Both now route through the owner. The raw scanPhasePlans helper stays: it answers are-plans-summarized, which genuinely is a different question. Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so an absent VERIFICATION.md means not complete everywhere. That retires 2645's verifier-disabled tolerance and inverts its Goodhart incentive - deleting the evidence now lowers completion instead of raising it. Guard hardened: block-form count gates and algebraic restatements are caught, and the header now discloses its remaining limits instead of overclaiming. Verified on the remote runner. * fix(#3186): route state sync through the owner and catch bare completed reads The matrix found 52 failures. 51 were fixtures asserting the old semantics: a phase with plans and summaries but no VERIFICATION.md used to count complete and correctly no longer does. Each fixture now carries a passing verification where that is what the test was actually about, rather than having its assertion weakened. The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync destructured scanPhasePlans().completed directly - a bare field read, not a comparison - and used it as a completion verdict, so state sync and state json disagreed on completed_phases for identical disk state. Routed through the owner. Guard gains shape (d): any read of .completed off a scanPhasePlans() result outside plan-scan.cts, in chained, destructured and indirect forms, function scoped with no line window. It cannot tell a summaries-met read from a completion read - that is data flow - so it flags every one and requires a written-reason exemption, which is the same discipline shapes a-c already use. The blind spot is disclosed in the header rather than overclaimed. The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks. Verified on the remote runner. * test(#3186): give the nested-plans sync fixture a passing verification Last 3 matrix failures were one failure echoing up two describe levels. Phase 01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict completed stayed 0 and no Progress change was emitted - correct new behavior, not a regression. Added the passing verification rather than dropping the Progress expectation, so the test still covers what #3257 is about: that a nested plans/ layout is counted and not undercounted. Probe against the built lib confirms Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3. * chore(#3186): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3306 exists. --------- Co-authored-by: sim <sim@local>
933 lines
43 KiB
JavaScript
933 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* Anti-divergence drift guard for the PHASE-COMPLETION predicate (epic
|
||
* #3180, issue #3186, ADR-3180 Decision 4, spec §7.4).
|
||
*
|
||
* The derivation this guard protects is "is phase P complete?", under the
|
||
* DISK-STRICT rule §7.4 now locks (amended on this branch, commit
|
||
* af92fd4c9, per the #2957 maintainer decision): `readVerificationStatus`
|
||
* is called UNCONDITIONALLY (no plan-count precondition), and a ticked
|
||
* ROADMAP checkbox carries NO machine authority over disk state. The
|
||
* designated owner is `src/verification.cts` · `isPhaseComplete` (Decision
|
||
* 1). Per ADR-3180 Amendment 3's standing rule this guard was written BEFORE
|
||
* any src/ file was touched (guard-first discovery), with
|
||
* `FUNCTION_SCOPED_EXEMPTIONS` below INTENTIONALLY EMPTY at that point. The
|
||
* implementation step (issue #3186) has since landed `isPhaseComplete` and
|
||
* migrated its call sites onto it, and populated the map — see its own
|
||
* comment immediately above its definition for the per-entry reasoning,
|
||
* including two DECLARED DEVIATIONS (`buildPhaseCompletionProjection`'s
|
||
* retained `implementation_complete` and `buildWorkstreamInventory`'s
|
||
* pure-projection boundary) not named by the design doc's own
|
||
* DO-NOT-MIGRATE list.
|
||
*
|
||
* Per ADR-3180 Decision 4(a) this guard discovers call sites by SCANNING THE
|
||
* WHOLE `src/` TREE, never an allowlist of known files. Per Decision 4(d)
|
||
* that surface is widened further: `src/` alone is itself the forbidden
|
||
* allowlist, one directory wide, so this guard ALSO scans the prompt-layer
|
||
* markdown (`gsd-core/workflows`, `commands`, `agents`, `skills`) — see the
|
||
* "PROMPT-LAYER PROSE DETECTION" section below.
|
||
*
|
||
* FOUR INDEPENDENT SHAPES are detected, matching issue #3186's dispatch (shape
|
||
* (d) added by the Phase 4 follow-up that closed the `cmdStateSync` /
|
||
* `src/state.cts` gap the remote matrix exposed — see shape (d)'s own header
|
||
* below):
|
||
*
|
||
* (a) CHECKBOX-DERIVED COMPLETION — a ROADMAP `- [x] Phase N` tick treated
|
||
* as completion evidence. Categorically wrong under disk-strict
|
||
* (§7.4's DECIDED resolution: "a ticked ROADMAP checkbox is a human
|
||
* annotation with no machine authority"). Detected as the PAIRED
|
||
* shape actually observed twice in this tree: an `if` statement whose
|
||
* condition references a roadmap/checkbox-derived "complete" boolean
|
||
* AND tests some status variable `!== 'complete'`, followed (within a
|
||
* small bounded window — this pairs ONE conditional with its OWN
|
||
* consequent statement, the same tight-pairing role
|
||
* `lint-state-field-drift.cjs`'s `LADDER_WINDOW_LINES` plays, NOT the
|
||
* big function-scoped co-occurrence Decision 4(a)'s Goodhart lesson
|
||
* targets) by an assignment of that SAME variable to the literal
|
||
* `'complete'`.
|
||
* (b) PLAN-COUNT PRECONDITION GATING A VERIFICATION READ — §7.4 names this
|
||
* exactly as `buildPhaseCompletionProjection`'s divergence: a
|
||
* `planCount > 0`-shaped gate that decides WHETHER
|
||
* `readVerificationStatus(` runs at all, rather than calling it
|
||
* unconditionally. Detected as FUNCTION-SCOPED co-occurrence (no
|
||
* window — Decision 4(a)'s Goodhart lesson, and Phase 5's "bounded
|
||
* window missed 7 of 14 copies" trap) of a count-gate ANYWHERE in the
|
||
* function's own body, together with a ternary- OR block/`if`-gated
|
||
* call to `readVerificationStatus(` (a call that is NOT the function's
|
||
* unconditional top-level statement) — the ternary form is a same-line
|
||
* `?` before the call; the block form is a call sitting inside the
|
||
* BODY of an `if (...)` whose OWN condition matches the count-gate
|
||
* (`findIfCountGateBlockLines`), which also catches `if (planCount >
|
||
* 0) { x = readVerificationStatus(...) }` — a #3186 review finding
|
||
* (4a): the prior version matched only the same-line ternary and
|
||
* produced zero hits on the block form. Known, disclosed limit: a
|
||
* multi-line `if` condition whose opening `{` lands on a later line
|
||
* than the condition's own closing `)` is not detected (every
|
||
* count-gate condition actually present in this tree is one line).
|
||
* (c) LOCAL RE-IMPLEMENTATION OF "COMPLETE" FROM COUNTS — a
|
||
* `summaryCount >= planCount`-shaped comparison (either operand
|
||
* order, or the algebraic `summaryCount - planCount >= 0` restatement
|
||
* and its mirror), computed locally instead of calling the owner.
|
||
* Known, disclosed limit: further algebraic restatements (an
|
||
* intermediate difference variable, `Math.max`/`!()`-wrapped forms,
|
||
* etc.) are not reliably matchable by a bounded, non-backtracking
|
||
* regex and are not attempted — see the regexes' own comment. This is
|
||
* the single sharpest textual signature this epic's divergent copies
|
||
* share: `buildPhaseCompletionProjection`, `buildStateFrontmatter`
|
||
* (via `scanPhasePlans`'s own `completed` field),
|
||
* `cmdRoadmapAnalyze`, `cmdRoadmapUpdatePlanProgress`, and
|
||
* `buildWorkstreamInventory` all independently hand-roll this exact
|
||
* comparison.
|
||
* (d) A BARE FIELD READ OF `scanPhasePlans(...).completed` OUTSIDE THE
|
||
* OWNER (`src/plan-scan.cts`) — the shape the remote matrix exposed:
|
||
* `cmdStateSync` (`src/state.cts`, now fixed) destructured
|
||
* `scanPhasePlans(dirPath).completed` directly and used it AS a
|
||
* completion verdict, with no comparison for shapes (a)/(b)/(c) to
|
||
* catch — a bare property read is not a re-derivation shape any of the
|
||
* other three detectors match. `scanPhasePlans` legitimately EXPOSES
|
||
* `.completed` (it is Phase 1's own owner for "are all plans
|
||
* summarized?", a real and distinct question from "is the phase
|
||
* complete?"), so reading the field is not inherently wrong — USING it
|
||
* as a completion verdict is. That distinction is DATA-FLOW (what the
|
||
* caller does with the value), which no textual guard can decide.
|
||
* KNOWN, DISCLOSED, HONEST LIMIT: this detector cannot tell a
|
||
* legitimate "are summaries caught up with plans" read from an illegal
|
||
* "is the phase complete" read — it flags EVERY read of `.completed`
|
||
* off a `scanPhasePlans(` result outside `src/plan-scan.cts` and
|
||
* relies on `FUNCTION_SCOPED_EXEMPTIONS` carrying a WRITTEN REASON per
|
||
* exempted function (see that map's own comment for each current
|
||
* entry's reasoning) rather than silently deciding the question for
|
||
* itself. Detected in three independent textual forms, ALL
|
||
* function-scoped (Decision 4(a), no line window — see below):
|
||
* - the direct chained form, `scanPhasePlans(...).completed`, on one
|
||
* statement — the call's own argument list is walked with a plain
|
||
* paren-depth counter (not a `[^)]*` regex), so a nested-paren
|
||
* argument (`scanPhasePlans(path.join(phasesDir, dir))`, the
|
||
* majority real shape in this tree) is still matched correctly;
|
||
* - the destructured form, `const { completed } = scanPhasePlans(…)`
|
||
* or the renamed-alias form `const { completed: isDone } =
|
||
* scanPhasePlans(…)`, on one statement;
|
||
* - the INDIRECT form — `const scan = scanPhasePlans(…)` binding the
|
||
* whole result to a variable, with `.completed` read off that same
|
||
* variable ANYWHERE else in the SAME named function, including on a
|
||
* different line — no bounded window (the same Phase-5 "bounded
|
||
* window missed 7 of 14 copies" lesson shape (b) already learned;
|
||
* `cmdStateSync`'s own real shape bound the result to a
|
||
* destructured `{ planCount: plans, summaryCount: summaries }`
|
||
* rather than a whole-object variable, so this indirect form is
|
||
* precautionary breadth for a shape not yet observed in this tree,
|
||
* not a shape already caught in the wild).
|
||
*
|
||
* DETECTION IS FUNCTION-SCOPED, NOT A BOUNDED LINE-WINDOW, for the
|
||
* *discovery* co-occurrence in each shape (shape (a)'s if/assignment pairing
|
||
* and shape (b)'s ternary-clause pairing are each ONE conditional's own two
|
||
* halves — the same narrow, unavoidable pairing `LADDER_WINDOW_LINES` bounds
|
||
* in the sibling state-field guard, not a re-derivation-hiding Goodhart
|
||
* target). Phase 5's first guard used a bounded line window between a
|
||
* ladder and its consuming call and MISSED 7 of 14 live copies inside the
|
||
* very function it scanned — that lesson is why shape (b)'s outer
|
||
* count-gate/gated-call pairing has NO line-distance bound at all: both
|
||
* signals need only appear somewhere in the SAME named function.
|
||
*
|
||
* COMMENT-AWARE. Phase 3's first `lint-phase-enumeration-drift.cjs` flagged
|
||
* JSDoc/inline comments that merely DOCUMENTED the derivation, not code that
|
||
* re-derives it (ADR-3180 Amendment 3). This guard reuses the SAME
|
||
* comment/string-stripping tokenizer `lint-state-field-drift.cjs` proved
|
||
* (`scanCode` below): comments and string/template literal CONTENTS are
|
||
* blanked before any detection regex runs, cross-line-aware for block
|
||
* comments and multi-line template literals.
|
||
*
|
||
* FUNCTION-SCOPED EXEMPTIONS ONLY, NEVER A WHOLE-FILE ALLOWLIST (Decision
|
||
* 4(a)/(d)): a whole-file exemption on the owner is precisely how
|
||
* `getMilestoneInfo` stayed invisible to an earlier guard (Decision 4(d)).
|
||
* `FUNCTION_SCOPED_EXEMPTIONS` is a `Map<relPath, Set<functionName>>`, kept
|
||
* EMPTY here because the owner (`src/verification.cts` · `isPhaseComplete`)
|
||
* does not exist yet — this comment, not a populated map, is what the
|
||
* implementation step (Phase 4's migration PR) replaces.
|
||
*
|
||
* Every regex below is small, bounded, and has no nested/overlapping
|
||
* quantifiers — the same ReDoS discipline `npm run lint:ci`'s CodeQL
|
||
* js/redos query verifies over every sibling drift guard.
|
||
*
|
||
* The tree-walk / root-confinement / sanitizer machinery is SHARED with the
|
||
* sibling drift guards via `scripts/lib/drift-scan.cjs` (ADR-3180 Decision
|
||
* 4).
|
||
*/
|
||
|
||
const path = require('node:path');
|
||
const driftScan = require('./lib/drift-scan.cjs');
|
||
const { MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
|
||
|
||
// ─── SHARED TOKENIZER + FUNCTION ATTRIBUTION (mirrors lint-state-field-drift.cjs) ──
|
||
//
|
||
// Two parallel per-line views from ONE single-pass, escape-aware character
|
||
// scan (not a regex — nothing for a backtracking engine to explore):
|
||
// - `detect[i]`: comments stripped, string/template CONTENTS kept verbatim
|
||
// (this is what every detection regex below runs against).
|
||
// - `braces[i]`: comments AND string/template CONTENTS stripped, used only
|
||
// for brace-depth counting, so a brace inside a string/comment never
|
||
// perturbs the depth count.
|
||
// `inBlockComment` / `inTemplate` are threaded ACROSS lines. Regex literals
|
||
// are not specially recognised — same documented, narrow, known limitation
|
||
// as the sibling guards (harmless for every regex literal actually present
|
||
// in this repo's completion-predicate call sites today, each balanced on
|
||
// its own line).
|
||
function scanCode(lines) {
|
||
const detect = new Array(lines.length);
|
||
const braces = new Array(lines.length);
|
||
let inBlockComment = false;
|
||
let inTemplate = false;
|
||
for (let li = 0; li < lines.length; li++) {
|
||
const line = lines[li];
|
||
let outDetect = '';
|
||
let outBraces = '';
|
||
let i = 0;
|
||
if (inTemplate) {
|
||
const start = i;
|
||
while (i < line.length) {
|
||
if (line[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (line[i] === '`') {
|
||
i++;
|
||
inTemplate = false;
|
||
break;
|
||
}
|
||
i++;
|
||
}
|
||
outDetect += line.slice(start, i);
|
||
if (inTemplate) {
|
||
detect[li] = outDetect;
|
||
braces[li] = '';
|
||
continue;
|
||
}
|
||
}
|
||
while (i < line.length) {
|
||
if (inBlockComment) {
|
||
const close = line.indexOf('*/', i);
|
||
if (close === -1) {
|
||
i = line.length;
|
||
break;
|
||
}
|
||
i = close + 2;
|
||
inBlockComment = false;
|
||
continue;
|
||
}
|
||
const ch = line[i];
|
||
if (ch === '/' && line[i + 1] === '/') {
|
||
i = line.length;
|
||
break;
|
||
}
|
||
if (ch === '/' && line[i + 1] === '*') {
|
||
inBlockComment = true;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'" || ch === '"') {
|
||
const quote = ch;
|
||
const start = i;
|
||
let j = i + 1;
|
||
while (j < line.length) {
|
||
if (line[j] === '\\') {
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (line[j] === quote) {
|
||
j++;
|
||
break;
|
||
}
|
||
j++;
|
||
}
|
||
outDetect += line.slice(start, j);
|
||
i = j;
|
||
continue;
|
||
}
|
||
if (ch === '`') {
|
||
const start = i;
|
||
let j = i + 1;
|
||
let closed = false;
|
||
while (j < line.length) {
|
||
if (line[j] === '\\') {
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (line[j] === '`') {
|
||
j++;
|
||
closed = true;
|
||
break;
|
||
}
|
||
j++;
|
||
}
|
||
if (!closed) {
|
||
outDetect += line.slice(start);
|
||
inTemplate = true;
|
||
i = line.length;
|
||
break;
|
||
}
|
||
outDetect += line.slice(start, j);
|
||
i = j;
|
||
continue;
|
||
}
|
||
outDetect += ch;
|
||
outBraces += ch;
|
||
i++;
|
||
}
|
||
detect[li] = outDetect;
|
||
braces[li] = outBraces;
|
||
}
|
||
return { detect, braces };
|
||
}
|
||
|
||
// Named function scope openers (mirrors lint-state-field-drift.cjs exactly):
|
||
// - `function NAME(...) {` — top-level OR nested, any indentation, an
|
||
// optional leading `export ` tolerated by `\b` alone.
|
||
const FUNCTION_DECL_RE = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/;
|
||
// - `const NAME = (...): ReturnType => {` — block-bodied arrow assigned to
|
||
// a const (an expression-bodied arrow `=> ({...})` never opens a new
|
||
// function frame; its `{` is an object literal, still counted toward
|
||
// brace depth, but attributes no name).
|
||
const ARROW_CONST_RE = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*(?::\s*[^=]+)?=>\s*\{/;
|
||
|
||
/**
|
||
* Pure: walk `lines` once, maintaining a brace-depth stack of open named
|
||
* function frames (deferring a multi-line `function NAME(` signature until
|
||
* the line whose brace count actually increases — see
|
||
* `lint-state-field-drift.cjs`'s `buildFunctionInfo` header for the full
|
||
* rationale this mirrors verbatim). Returns `{ innermostAt, detect }`:
|
||
* `innermostAt[i]` is the name of the innermost named function open at line
|
||
* `i` (or `null` at module scope), `detect[i]` is the comment/string-
|
||
* preserving-but-stripped-of-comments view every detector regex runs
|
||
* against.
|
||
*/
|
||
function buildFunctionInfo(lines) {
|
||
const { detect, braces } = scanCode(lines);
|
||
const innermostAt = new Array(lines.length).fill(null);
|
||
const stack = []; // { name, openDepth }
|
||
let depth = 0;
|
||
let pendingDeclName = null;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
const braceCode = braces[i];
|
||
|
||
let immediateName = null;
|
||
if (detectCode.trim()) {
|
||
const arrowMatch = ARROW_CONST_RE.exec(detectCode);
|
||
if (arrowMatch) {
|
||
immediateName = arrowMatch[1];
|
||
} else {
|
||
const declMatch = FUNCTION_DECL_RE.exec(detectCode);
|
||
if (declMatch) pendingDeclName = declMatch[1];
|
||
}
|
||
}
|
||
|
||
const opens = (braceCode.match(/\{/g) || []).length;
|
||
const closes = (braceCode.match(/\}/g) || []).length;
|
||
depth += opens - closes;
|
||
|
||
if (immediateName) stack.push({ name: immediateName, openDepth: depth });
|
||
|
||
if (pendingDeclName) {
|
||
if (opens > 0) {
|
||
stack.push({ name: pendingDeclName, openDepth: depth });
|
||
pendingDeclName = null;
|
||
} else if (detectCode.includes(';')) {
|
||
pendingDeclName = null;
|
||
}
|
||
}
|
||
|
||
while (stack.length > 0 && depth < stack[stack.length - 1].openDepth) stack.pop();
|
||
|
||
innermostAt[i] = stack.length > 0 ? stack[stack.length - 1].name : null;
|
||
}
|
||
return { innermostAt, detect };
|
||
}
|
||
|
||
/**
|
||
* §7.4/#3186 review finding 4(a): the BLOCK form of shape (b)'s gate — `if
|
||
* (planCount > 0) { … readVerificationStatus(…) … }` — is textually
|
||
* indistinguishable from an unrelated `if` block by a same-line regex; the
|
||
* prior ternary-only `GATED_VERIFICATION_READ_RE` produced zero hits on it.
|
||
* Deliberately narrower than a generic "is this line nested at all" check
|
||
* (which would false-positive on every unrelated wrapper — a `try` block, a
|
||
* `withPlanningLock(cwd, () => { … })` callback, a `for` loop — none of
|
||
* which are a plan-count GATE): only lines inside the BODY of an `if (...)`
|
||
* whose OWN condition matches `COUNT_GATE_RE` are marked. Line-granularity
|
||
* brace bookkeeping (mirrors `buildFunctionInfo`'s own style), not a
|
||
* per-character brace matcher — a multi-line `if` condition whose `{` lands
|
||
* on a later line than `extractIfCondition`'s reported `endLine` is a known,
|
||
* disclosed limitation (real count-gates in this tree are one-line
|
||
* conditions; see the module header).
|
||
*/
|
||
function findIfCountGateBlockLines(lines) {
|
||
const { detect, braces } = scanCode(lines);
|
||
const gated = new Array(lines.length).fill(false);
|
||
const stack = []; // { openDepth } for open if-blocks whose condition is a count-gate
|
||
let depth = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
const braceCode = braces[i];
|
||
|
||
let isCountGateIfHeader = false;
|
||
if (detectCode.trim()) {
|
||
const ifMatch = IF_OPEN_RE.exec(detectCode);
|
||
if (ifMatch) {
|
||
const startCol = ifMatch.index + ifMatch[0].length;
|
||
const condition = extractIfCondition(detect, i, startCol);
|
||
if (condition && COUNT_GATE_RE.test(condition.text)) isCountGateIfHeader = true;
|
||
}
|
||
}
|
||
|
||
const opens = (braceCode.match(/\{/g) || []).length;
|
||
const closes = (braceCode.match(/\}/g) || []).length;
|
||
depth += opens - closes;
|
||
|
||
// A line is "inside" a count-gate if-block when either a PRIOR line
|
||
// already opened one and it has not yet closed, or THIS line's own `if`
|
||
// header both matches the gate and opens its body on the same line
|
||
// (`if (planCount > 0) { … }` — the exact shape in the finding).
|
||
gated[i] = stack.length > 0 || (isCountGateIfHeader && opens > closes);
|
||
|
||
if (isCountGateIfHeader && opens > closes) stack.push({ openDepth: depth });
|
||
|
||
while (stack.length > 0 && depth < stack[stack.length - 1].openDepth) stack.pop();
|
||
}
|
||
return gated;
|
||
}
|
||
|
||
// ─── SHAPE (a): CHECKBOX-DERIVED COMPLETION ────────────────────────────────
|
||
//
|
||
// The `if` half of the pairing: a condition referencing a roadmap/checkbox-
|
||
// derived "complete" boolean (`roadmapComplete`, `roadmap_complete`, any
|
||
// `\w*roadmap\w*complete\w*` spelling — case-insensitive, both real sites in
|
||
// this tree use exactly `roadmapComplete`) AND testing some OTHER status
|
||
// variable against the literal `!== 'complete'` (single or double quotes).
|
||
const IF_OPEN_RE = /\bif\s*\(/;
|
||
const ROADMAP_COMPLETE_IDENT_RE = /\broadmap\w*complete\w*\b/i;
|
||
const STATUS_NEQ_COMPLETE_RE = /\b([A-Za-z_$][\w$]*)\s*!==\s*['"]complete['"]/;
|
||
|
||
// The consequent half: an assignment of the SAME status variable to the
|
||
// literal `'complete'`. The negative lookbehind excludes `!==`/`<=`/`>=`/`==`
|
||
// (each of which also contains a bare `=` immediately before a quote) so
|
||
// this never re-matches the `if` line's own `!== 'complete'` clause — a
|
||
// single bounded character class, not a nested quantifier.
|
||
const ASSIGN_COMPLETE_RE = /(?<![!<>=])=\s*['"]complete['"]/;
|
||
|
||
// How many lines the consequent assignment may trail its own `if` line by.
|
||
// This bounds ONE conditional's own two halves (condition, then its direct
|
||
// consequent statement) — the same narrow role `LADDER_WINDOW_LINES` plays
|
||
// in the sibling state-field guard, not the function-scoped, unbounded
|
||
// co-occurrence Decision 4(a)'s Goodhart lesson targets for shape (b) below.
|
||
const CHECKBOX_OVERRIDE_WINDOW_LINES = 4;
|
||
|
||
// The `if (...)` condition in both real sites nests a SECOND, unrelated
|
||
// parenthesised group (`(completion.phase_complete || planCount === 0)`), so
|
||
// a single-line, no-nested-parens regex over the whole condition
|
||
// systematically MISSES the second site. Extracted by plain paren-depth
|
||
// counting instead — a linear character walk, not a regex, so there is
|
||
// nothing for a backtracking engine to explore regardless of nesting depth.
|
||
// Bounded to IF_CONDITION_MAX_LINES so a pathologically unterminated `if (`
|
||
// cannot walk the whole file.
|
||
const IF_CONDITION_MAX_LINES = 10;
|
||
|
||
function extractIfCondition(detectLines, startLine, startCol) {
|
||
let depth = 1; // the `(` at startCol already opened the condition
|
||
let text = '';
|
||
const endLineLimit = Math.min(detectLines.length, startLine + IF_CONDITION_MAX_LINES);
|
||
for (let li = startLine; li < endLineLimit; li++) {
|
||
const line = detectLines[li];
|
||
const from = li === startLine ? startCol : 0;
|
||
for (let ci = from; ci < line.length; ci++) {
|
||
const ch = line[ci];
|
||
if (ch === '(') depth++;
|
||
else if (ch === ')') {
|
||
depth--;
|
||
if (depth === 0) return { text, endLine: li };
|
||
}
|
||
text += ch;
|
||
}
|
||
text += '\n';
|
||
}
|
||
return null; // unterminated within the bound — treated as no match
|
||
}
|
||
|
||
function findChecklistOverrideDrift(text, relPath, exemptFunctions) {
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
const { innermostAt, detect } = buildFunctionInfo(lines);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
if (!detectCode.trim()) continue;
|
||
const ifMatch = IF_OPEN_RE.exec(detectCode);
|
||
if (!ifMatch) continue;
|
||
const startCol = ifMatch.index + ifMatch[0].length;
|
||
const condition = extractIfCondition(detect, i, startCol);
|
||
if (!condition) continue;
|
||
if (!ROADMAP_COMPLETE_IDENT_RE.test(condition.text)) continue;
|
||
const neqMatch = STATUS_NEQ_COMPLETE_RE.exec(condition.text);
|
||
if (!neqMatch) continue;
|
||
const varName = neqMatch[1];
|
||
const limit = Math.min(lines.length, condition.endLine + 1 + CHECKBOX_OVERRIDE_WINDOW_LINES);
|
||
for (let j = condition.endLine + 1; j < limit; j++) {
|
||
const assignCode = detect[j];
|
||
if (!assignCode.trim()) continue;
|
||
if (!assignCode.includes(varName)) continue;
|
||
if (!ASSIGN_COMPLETE_RE.test(assignCode)) continue;
|
||
const fn = innermostAt[j] || innermostAt[i];
|
||
if (fn && exemptFunctions && exemptFunctions.has(fn)) break;
|
||
out.push({ line: j + 1, found: lines[j].trim().slice(0, MAX_REGEX_LITERAL_LEN), shape: 'a', fn: fn || null });
|
||
break;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ─── SHAPE (b): PLAN-COUNT PRECONDITION GATING A VERIFICATION READ ─────────
|
||
//
|
||
// A "count > 0"-shaped gate — any identifier containing `count`
|
||
// (case-insensitive) compared `> 0`. Function-scoped presence only (no
|
||
// window): §7.4 names this exact shape as `planCount > 0`, but the
|
||
// identifier is matched generically so a differently-named count (or a
|
||
// future `isPhaseComplete` re-implementation reusing the same gate under a
|
||
// new name) is still caught.
|
||
const COUNT_GATE_RE = /\b[A-Za-z_$][\w$]*count\b\s*>\s*0\b/i;
|
||
|
||
// The call this derivation's owner (`readVerificationStatus`, wrapped by the
|
||
// not-yet-existing `isPhaseComplete`) must run UNCONDITIONALLY per §7.4. A
|
||
// line where `readVerificationStatus(` is reached via a ternary — a `?`
|
||
// appearing anywhere earlier on the SAME line — is a GATED call, not an
|
||
// unconditional one. `[^\n]*` is bounded by the line itself (no backtracking
|
||
// blow-up: a single non-newline character class followed by one literal).
|
||
const VERIFICATION_READ_CALL_RE = /\breadVerificationStatus\(/;
|
||
const GATED_VERIFICATION_READ_RE = /\?[^\n]*\breadVerificationStatus\(/;
|
||
|
||
function findGatedVerificationReadDrift(text, relPath, exemptFunctions) {
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
const { innermostAt, detect } = buildFunctionInfo(lines);
|
||
const ifCountGateBlockLines = findIfCountGateBlockLines(lines);
|
||
|
||
const countGateFns = new Set();
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
if (!detectCode.trim()) continue;
|
||
if (COUNT_GATE_RE.test(detectCode)) {
|
||
const fn = innermostAt[i];
|
||
if (fn) countGateFns.add(fn);
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
if (!detectCode.trim()) continue;
|
||
if (!VERIFICATION_READ_CALL_RE.test(detectCode)) continue;
|
||
// #3186 review finding 4(a): "gated" is EITHER a same-line ternary `?`
|
||
// before the call, OR the call sitting inside the BODY of an `if (...)`
|
||
// block whose own condition is a count-gate (`findIfCountGateBlockLines`)
|
||
// — the latter catches the block form (`if (planCount > 0) { …
|
||
// readVerificationStatus(…) … }`), which the ternary-only regex produced
|
||
// zero hits on.
|
||
const ternaryGated = GATED_VERIFICATION_READ_RE.test(detectCode);
|
||
const blockGated = ifCountGateBlockLines[i];
|
||
if (!ternaryGated && !blockGated) continue;
|
||
const fn = innermostAt[i];
|
||
if (!fn || !countGateFns.has(fn)) continue;
|
||
if (exemptFunctions && exemptFunctions.has(fn)) continue;
|
||
out.push({ line: i + 1, found: lines[i].trim().slice(0, MAX_REGEX_LITERAL_LEN), shape: 'b', fn });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ─── SHAPE (c): LOCAL RE-IMPLEMENTATION OF "COMPLETE" FROM COUNTS ──────────
|
||
//
|
||
// `summaryCount >= planCount` (either identifier order, either comparison
|
||
// direction) — the single textual signature `buildPhaseCompletionProjection`,
|
||
// `scanPhasePlans`, `cmdRoadmapAnalyze`, `cmdRoadmapUpdatePlanProgress`, and
|
||
// `buildWorkstreamInventory` each independently hand-roll. Case-insensitive
|
||
// so `SummaryCount`/`summary_count` spellings are still caught; both operand
|
||
// orders are covered by two small, non-overlapping alternatives.
|
||
const SUMMARY_GE_PLAN_RE = /\bsummar\w*count\w*\s*>=\s*\w*plan\w*count\w*/i;
|
||
const PLAN_LE_SUMMARY_RE = /\bplan\w*count\w*\s*<=\s*\w*summar\w*count\w*/i;
|
||
|
||
// §7.4/#3186 review finding 4(b): the literal `>=`/`<=` regexes above missed
|
||
// the algebraic restatement `summaryCount - planCount >= 0` (and its mirror,
|
||
// `planCount - summaryCount <= 0`) — same comparison, no literal `>=`/`<=`
|
||
// between the two count identifiers. Widened to cover exactly these two
|
||
// zero-compared-difference shapes; each is a single bounded alternative, no
|
||
// nested/overlapping quantifiers.
|
||
//
|
||
// KNOWN, DISCLOSED LIMIT (not claimed covered): arbitrary further algebraic
|
||
// restatements — `!(planCount > summaryCount)`, a difference stored in an
|
||
// intermediate variable before the comparison, `Math.max(0, planCount -
|
||
// summaryCount) === 0`, etc. — are NOT reliably detectable by a bounded,
|
||
// non-backtracking regex and are not attempted here. This is a genuine gap,
|
||
// not swept under "et cetera": the header above disclosed it as such rather
|
||
// than claiming a wider net than the regexes actually cast.
|
||
const SUMMARY_MINUS_PLAN_GE_ZERO_RE = /\bsummar\w*count\w*\s*-\s*\w*plan\w*count\w*\s*>=\s*0\b/i;
|
||
const PLAN_MINUS_SUMMARY_LE_ZERO_RE = /\bplan\w*count\w*\s*-\s*\w*summar\w*count\w*\s*<=\s*0\b/i;
|
||
|
||
function findLocalCompletionCountDerivationDrift(text, relPath, exemptFunctions) {
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
const { innermostAt, detect } = buildFunctionInfo(lines);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const detectCode = detect[i];
|
||
if (!detectCode.trim()) continue;
|
||
if (
|
||
!SUMMARY_GE_PLAN_RE.test(detectCode)
|
||
&& !PLAN_LE_SUMMARY_RE.test(detectCode)
|
||
&& !SUMMARY_MINUS_PLAN_GE_ZERO_RE.test(detectCode)
|
||
&& !PLAN_MINUS_SUMMARY_LE_ZERO_RE.test(detectCode)
|
||
) continue;
|
||
const fn = innermostAt[i];
|
||
if (fn && exemptFunctions && exemptFunctions.has(fn)) continue;
|
||
out.push({ line: i + 1, found: lines[i].trim().slice(0, MAX_REGEX_LITERAL_LEN), shape: 'c', fn: fn || null });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ─── SHAPE (d): scanPhasePlans(...).completed READ AS A COMPLETION VERDICT ─
|
||
//
|
||
// See the module header's shape (d) entry for the full rationale and the
|
||
// three textual forms detected below. `FUNCTION_SCOPED_EXEMPTIONS` is
|
||
// SHARED with shapes (a)/(b)/(c) — the same per-file, per-function map, so a
|
||
// function already exempted for one shape (e.g. `scanPhasePlans` itself,
|
||
// which legitimately builds the `completed` field it returns) is exempted
|
||
// for shape (d) too, and a function newly exempted for shape (d) must carry
|
||
// its own written reason in that map's comment exactly like the others.
|
||
const SCAN_CALL_TOKEN = 'scanPhasePlans(';
|
||
const DOT_COMPLETED_RE = /^\.completed\b/;
|
||
const SCAN_ASSIGN_VAR_RE = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*scanPhasePlans\(/;
|
||
// Bounded: `[^{}]*` is a single, non-nested, non-overlapping quantifier — no
|
||
// backtracking blow-up regardless of destructure-pattern length. Real
|
||
// destructuring assignments in this tree are single-line (the same
|
||
// assumption `SCAN_ASSIGN_VAR_RE` and every sibling shape's regexes make).
|
||
const DESTRUCTURE_ASSIGN_RE = /\{([^{}]*)\}\s*=\s*scanPhasePlans\(/;
|
||
const COMPLETED_KEY_RE = /\bcompleted\b/;
|
||
|
||
function isWordChar(ch) {
|
||
return !!ch && /[A-Za-z0-9_$]/.test(ch);
|
||
}
|
||
|
||
// Plain paren-depth counter over a SINGLE line (not a regex — nothing for a
|
||
// backtracking engine to explore), so a nested-paren call argument (e.g.
|
||
// `scanPhasePlans(path.join(phasesDir, dir))`, the majority real shape in
|
||
// this tree) still resolves to the call's own true closing `)`. Confined to
|
||
// one line: every real `scanPhasePlans(` call site in this tree closes on
|
||
// the line it opens on (the same assumption the rest of this file's
|
||
// detectors make about this specific call).
|
||
function findCallEndOnLine(line, afterOpenParenIdx) {
|
||
let depth = 1;
|
||
for (let i = afterOpenParenIdx; i < line.length; i++) {
|
||
const ch = line[i];
|
||
if (ch === '(') depth++;
|
||
else if (ch === ')') {
|
||
depth--;
|
||
if (depth === 0) return i + 1;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function findScanPhasePlansCompletedReadDrift(text, relPath, exemptFunctions) {
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
const { innermostAt, detect } = buildFunctionInfo(lines);
|
||
const isExemptAt = (i) => {
|
||
const fn = innermostAt[i];
|
||
return !!(fn && exemptFunctions && exemptFunctions.has(fn));
|
||
};
|
||
|
||
// fnKey: the innermost function name at the ASSIGNMENT site, or this
|
||
// sentinel for module-scope assignments (never collides with a real
|
||
// identifier — function names cannot start with U+0000).
|
||
const FN_KEY_MODULE = ' |