Files
msd-core/scripts/lint-state-field-drift.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

806 lines
41 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Anti-divergence drift guard for the STATE.md FIELD-EXTRACTION FALLBACK
* CHAIN (epic #3180, issue #3187, ADR-3180 Decision 4, spec §7.7).
*
* The derivation this guard protects is "read a STATE.md field: prefer the
* YAML frontmatter scalar (string, trimmed-non-empty; or number/boolean
* coerced to a string), else fall back to the body field via
* `stateExtractField(body, bodyField)`". `src/state-document.cts` is
* DESIGNATED the single canonical owner of this grammar (issue #3187 Phase
* 5), but as of this guard's authorship (Phase 5's guard-first step, per
* ADR-3180 Amendment 3's standing rule) the canonical function does not yet
* exist there — this script is written to discover every existing
* re-derivation BEFORE any src/ file is touched, exactly the ordering
* `lint-plan-count-drift.cjs` and its siblings established.
*
* 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. Per
* Decision 4(d) that surface is widened further still: `src/` alone is
* itself the forbidden allowlist, one directory wide, so this guard ALSO
* scans the prompt-layer markdown (`msd-core/workflows`, `commands`,
* `agents`, `skills`) for a PROSE re-derivation of the same chain — see the
* "PROMPT-LAYER PROSE DETECTION" section below `findStateFieldDrift`.
*
* DETECTION: FUNCTION-SCOPED CO-OCCURRENCE, not a bounded line-window.
* An earlier draft of this guard used a fixed line-distance window between
* the ladder and the fallback call. That shape was REJECTED: it produced a
* documented "known miss" on live copies inside the very function it was
* scanning (state.cts `cmdStateSnapshot`'s `Current Plan` / `Total Plans in
* Phase` / `Status` / `Progress` / `Last Activity` reads all sit further
* from the ladder than any defensible line count), which means the guard
* could report "0 re-derivations" after only the nearest few copies were
* migrated while several more of the SAME kind survived untouched in the
* SAME function — "a zero it did not earn" (ADR-3180 Decision 4(a)). A fixed
* N is also trivially gameable by reflow (Goodhart's law): moving a call one
* line further from its ladder silences the guard without changing the
* derivation at all. The window is dropped entirely; there is no magic
* number anywhere in this detection.
*
* The invariant instead: a NAMED FUNCTION that BOTH (a) contains a
* "frontmatter scalar coercion ladder" — the SAME operand compared via
* `typeof OPERAND === 'number'` and `typeof OPERAND === 'boolean'`
* (TYPEOF_TIER_CLAUSE_RE, order-independent, member/computed operands
* included, an optional `typeof OPERAND === 'string'` tier tolerated
* anywhere among them — see the ternary shape in `cmdStatePrune` below),
* within LADDER_WINDOW_LINES of each other — ANYWHERE in its own body,
* INCLUDING inside a nested closure it defines, AND (b) calls
* `stateExtractField(` anywhere in that same body, is re-deriving the
* fm-else-body fallback chain. EVERY `stateExtractField(` call line inside
* such a function is reported, not just the first.
*
* LADDER DETECTION, evasion-resistant shapes (see "LADDER DETECTION DETAIL"
* below for the full account, including the one shape still NOT caught):
* - operand may be a bare identifier (`v`), a dotted member expression
* (`fm.key`), or a computed access (`fm[key]`) — matched via
* OPERAND_SOURCE and compared as captured TEXT, not a bare-identifier
* backreference.
* - the two required tiers ('number', 'boolean') may appear in EITHER
* order; an optional third 'string' tier may sit anywhere among them.
* - the two clauses may sit on the SAME line (the classic single-line
* `||` chain) or on DIFFERENT lines within LADDER_WINDOW_LINES of each
* other — including as two entirely separate `if` statements, not just
* one expression wrapped across a line break.
*
* FUNCTION ATTRIBUTION. Two declaration shapes are recognised as opening a
* new named function scope:
* - `function NAME(...) {` (FUNCTION_DECL_RE) — top-level OR nested, at
* any indentation. `src/smart-entry.cts`'s `fmScalar` (line 144) is this
* shape, and is itself top-level.
* - `const NAME = (...): ReturnType => {` (ARROW_CONST_RE) — an arrow
* function assigned to a `const`, with a block body (`=> {`, not an
* expression body like `=> ({...})`, which never opens a new function
* frame — its `{` is an object literal, still counted toward brace
* depth, but attributes no name). `src/state.cts`'s `cmdStateSnapshot`
* defines its `fmScalar` (line 1464) this way, NESTED inside
* `cmdStateSnapshot` itself.
* `ARROW_CONST_RE`'s pattern requires `=>\s*\{` literally, so it only ever
* matches a line that already carries its opening brace — pushed
* immediately. `FUNCTION_DECL_RE` carries no such guarantee: a multi-line
* signature (parameters and/or a return-type annotation spilling onto later
* lines) matches on a line with NO `{` at all. An earlier version of this
* guard pushed such a match immediately anyway, recording `openDepth` at the
* ENCLOSING scope's depth rather than the function's own — for a top-level
* function, `openDepth: 0`, and because real code never reaches negative
* depth, a frame pushed with `openDepth: 0` could NEVER pop, sitting at the
* bottom of the stack for the rest of the file and silently misattributing
* every later line with no OTHER open frame to it. `src/state.cts`'s
* `preferNewerLastActivity` (a 4-line signature, `{` on its own line) is the
* live instance that surfaced this; it caused no observed false violation
* only because nothing ever called `stateExtractField(` at true module scope
* after it, not because the tracking was sound. `buildFunctionInfo` now
* DEFERS a `FUNCTION_DECL_RE` match (`pendingDeclName`) across lines until
* the first subsequent line whose brace count actually increases, and pushes
* the frame THERE, with `openDepth` computed from that line's `depth` —
* matching every other frame's push convention. A pending name is abandoned
* (never pushed) if a `;` terminates the statement before any `{` appears —
* a type-only declaration, `declare function`, or overload signature, none
* of which open a body.
* Function scopes NEST via a brace-depth stack: entering either shape pushes
* a frame; the frame pops once brace depth returns below the depth recorded
* when it was pushed. A line is attributed to the INNERMOST currently-open
* named frame (falling back to whatever enclosing frame IS open — typically
* the nearest enclosing top-level function — when a line sits between two
* sibling nested scopes; module-level code with no open frame is
* unattributed and therefore never a violation, matching "if you cannot
* identify one" in the design brief).
*
* TRANSITIVE ladder attribution is what makes `cmdStateSnapshot` (whose
* OWN top-level statements never spell the ladder themselves — only its
* nested `fmScalar` closure does) still register as ladder-bearing: when
* LADDER_RE matches a line, EVERY frame currently open on the stack at that
* point — not just the innermost — is marked ladder-bearing, because a
* nested closure's body is lexically part of every one of its enclosing
* functions' own bodies. `stateExtractField(` calls are attributed to the
* INNERMOST frame only (no transitivity needed there: the call already sits
* directly inside whichever frame is innermost at that point).
*
* BRACE-DEPTH COUNTING runs over `scanCode`'s per-line, cross-file output
* (see that function's own header for the full rationale and the concrete
* bug its cross-line comment/template tracking fixes) — comments and
* quoted/backtick string and template literal CONTENTS are already removed
* before a single brace is counted, escape-aware and threaded across line
* boundaries, so neither a brace inside a string (`{ label: '{' }`) nor one
* inside a multi-line block comment or template literal perturbs the depth
* count. It does NOT specially recognise regex literals (a `{` inside a
* `/.../ ` quantifier, e.g. `/x{2,3}/`, is counted as a plain character);
* see `scanCode`'s header for why every such literal actually present in
* `src/` today is harmless (balanced on its own line).
*
* MUST-NOT-FLAG case verified by running the guard (the earlier "case
* variant chain" exemption for `src/state.cts:1488` was WRONG and is
* SUPERSEDED — see the header of the guard's initial version in git history
* for the retracted reasoning; `cmdStateSnapshot` is ladder-bearing, so
* *every* `stateExtractField(` call inside it, including line 1488, is
* correctly a violation now):
* - `src/smart-entry.cts`'s `fmScalarKey` (lines 152-158): its own
* `typeof v === 'number' || typeof v === 'boolean'` ladder (line 156)
* reads a value out of a NESTED frontmatter object and never calls
* `stateExtractField(` anywhere in its own body — it is ladder-bearing
* but call-free, so it is correctly never flagged. This is the live
* control case proving the guard still distinguishes "has a ladder" from
* "re-derives the fallback chain": a ladder alone, with no body
* fallback call in the same function, is a different question (reading
* a nested frontmatter object, full stop) and stays silent.
* - Any ladder or `stateExtractField(` call appearing inside a `//` line
* comment or a `/* *\/`-style block comment (single- or multi-line).
* `scanCode` blanks comment text — cross-line-aware — before either
* regex runs, so prose describing this derivation is never mistaken for
* a copy of it (ADR-3180 Amendment 3's "trains readers to reflexively
* exempt documentation" note).
*
* FUNCTION-SCOPED EXEMPTIONS (per ADR-3180 Decision 4(a): NEVER a bare
* whole-file allowlist — Decision 4(d) records that a whole-file exemption
* on the owner is precisely how `getMilestoneInfo` stayed invisible to an
* earlier guard). `src/state-document.cts` is the owner of this grammar;
* its `stateFieldValue` (added for issue #3187 Phase 5, landed in this same
* working tree while this guard was being authored — see the guard's commit
* history / PR for the exact sequencing) IS the canonical
* frontmatter-scalar-then-body-field chain, not a copy of it, so it is the
* ONLY entry in FUNCTION_SCOPED_EXEMPTIONS. Every other function in
* `state-document.cts` — including any future re-derivation added anywhere
* else in that file — is still scanned and still flagged (mirrors
* `lint-completion-ratio-drift.cjs`'s `FUNCTION_SCOPED_EXEMPTIONS` for
* `clampPercent`/`clampPercentFromFraction`).
*
* Every regex below is small, bounded, and has no nested/overlapping
* quantifiers; the string-literal and brace scans are plain escape-aware
* character loops, not regexes, so there is nothing for a backtracking
* engine to explore. `npm run lint:ci` runs CodeQL js/redos over this repo;
* mirrors the ReDoS discipline of the sibling drift guards.
*
* The tree-walk / root-confinement / sanitizer machinery is SHARED with the
* sibling drift guards via `scripts/lib/drift-scan.cjs` (ADR-3180 Decision
* 4). This guard's detection shape needs no regex-literal extraction, so it
* does not use `readRegexLiteralAt`; the reported fragment is simply the
* trimmed source line, bounded to MAX_REGEX_LITERAL_LEN characters.
*
* LADDER DETECTION DETAIL (evasion history). An earlier version of this
* guard's ladder pattern was a single regex, `typeof (\w+) === 'number' \|\|
* typeof \1 === 'boolean'`, tested against ONE line. An isolated adversarial
* review found three live-shaped ways past it, all verified to produce ZERO
* violations against a real re-derivation:
* (a) a member-expression or computed operand — `typeof fm.key ===
* 'number' || typeof fm.key === 'boolean'` — never matched, because
* the bare-identifier backreference (`\w+` then `\1`) cannot match
* `fm.key`/`fm[key]` at all.
* (b) the tiers in the opposite order — `typeof v === 'boolean' ||
* typeof v === 'number'` — never matched, because the pattern
* hardcoded 'number' before 'boolean' with no alternative ordering.
* This one is plausible from an ordinary code-review reformat, not
* deliberate evasion.
* (c) the ladder split across two lines, or written as two separate `if`
* statements instead of one `||` chain — never matched, because
* detection ran per-line with no tolerance for the pair spanning more
* than one line.
* The fix: OPERAND_SOURCE (identifier / dotted member / computed access,
* compared as captured TEXT rather than a bare-identifier backreference),
* TYPEOF_TIER_CLAUSE_RE (one clause at a time, tier-order-independent,
* collected via a `Map<operand, Set<tier>>`), and a small bounded
* LADDER_WINDOW_LINES sliding window so the two required clauses need only
* sit within a few lines of each other, not on the identical line. All
* three evasion shapes above are now covered by
* `tests/state-field-drift.test.cjs` (D3c/D3d/D3e).
*
* KNOWN, ACCEPTED limits of this scan (honest, not exhaustive by
* construction — this is a bounded regex-based scan, not a parser):
* - Cross-function: a re-derivation whose ladder and fallback call sit in
* two DIFFERENT named functions with no shared enclosing scope (e.g. a
* ladder in one file-level helper, consumed by a caller in another
* function that itself calls `stateExtractField(` for an unrelated
* field) is not caught — this guard's unit is "one named function's own
* body, including its nested closures", not the whole call graph. That
* is left to code review, not this regex. This co-occurrence check is
* deliberately FUNCTION-SCOPED with NO line-distance window at all (see
* the module-level "DETECTION" note above) — do not confuse this with
* LADDER_WINDOW_LINES, which bounds a different, much tighter pairing
* (the ladder's own two clauses, which are always part of the SAME
* conditional expression by construction, not an arbitrary call
* anywhere later in the function).
* - Ladder clauses further apart than LADDER_WINDOW_LINES lines: a ladder
* whose 'number' and 'boolean' clauses are more than
* LADDER_WINDOW_LINES lines apart (e.g. separated by an unrelated
* intervening block of code, not just the couple of lines a single
* conditional or two adjacent `if`s span) is not caught. No such shape
* has been observed in this codebase; if one appears, raise
* LADDER_WINDOW_LINES rather than silently accepting the miss.
* - Non-`typeof`-shaped coercion checks: a ladder rewritten through a
* `switch (typeof v)`, a helper function abstracting the check (e.g.
* `isNumberOrBoolean(v)`), or any comparison operator other than
* `===` (e.g. `typeof v == 'number'`) is not recognised — the pattern
* is `typeof OPERAND === 'TIER'` literally, not "any type-coercion
* test with equivalent runtime behaviour."
* - Nested computed access: `a[b[c]]` is not modelled as a single
* operand — the computed-content class excludes `]`, so nested
* brackets truncate the captured operand at the first `]` rather than
* matching the whole expression. Not observed in this codebase's
* ladders today.
* - Whitespace-sensitive operand identity: `fm[key]` and `fm[ key ]` are
* compared as distinct operand TEXT (no normalisation), so a ladder
* whose two clauses format the same computed access differently could
* under-detect. Not observed in this codebase's ladders today.
*/
const path = require('node:path');
const driftScan = require('./lib/drift-scan.cjs');
const { MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
// A ladder operand: a bare identifier (`v`), a dotted member expression
// (`fm.key`, `fm.a.b`), or a computed/bracket access (`fm[key]`), repeated
// via a single bounded alternation. No nested/overlapping quantifiers: the
// bracket-content class (`[^\]\r\n]{1,80}`) is one bounded character class,
// not a quantifier nested inside another quantifier, so there is nothing
// for a backtracking engine to explore. The captured TEXT (not a bare-
// identifier backreference) is what two clauses are compared against for
// "same operand" — see TYPEOF_TIER_CLAUSE_RE below and buildFunctionInfo's
// ladder-window accumulation.
const OPERAND_SOURCE = String.raw`[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]\r\n]{1,80}\])*`;
// One ladder clause: `typeof OPERAND === 'TIER'`, TIER being one of the
// three coercion tiers this derivation ever compares against. Run with the
// `g` flag over a single line's `detect` text so every clause that line
// carries is collected (a single-line `||` chain carries two or three; a
// clause split onto its own line, or written as a standalone `if`, carries
// one). No trailing `\b` after the closing quote: the quote itself is
// already an unambiguous, non-word boundary — see the historical note this
// replaced for why a trailing `\b` there silently matches nothing at all.
const TYPEOF_TIER_CLAUSE_RE = new RegExp(String.raw`\btypeof\s+(${OPERAND_SOURCE})\s*===\s*'(number|boolean|string)'`, 'g');
// A ladder is CONFIRMED when the SAME operand carries both a 'number' clause
// and a 'boolean' clause (in either order; an additional 'string' clause
// anywhere among them does not prevent this) within this many consecutive
// lines of each other. Small and bounded, and DELIBERATELY NOT the same
// concept as the ladder-to-`stateExtractField(` co-occurrence check, which
// stays function-scoped with no window at all (see the module header's
// "DETECTION" and "LADDER DETECTION DETAIL" notes for why these two
// pairings are not interchangeable).
const LADDER_WINDOW_LINES = 6;
// The body-fallback call whose presence, inside a ladder-bearing function,
// makes that function a re-derivation of the "frontmatter-else-body" grammar.
const STATE_EXTRACT_FIELD_CALL_RE = /\bstateExtractField\(/;
// Named function scope openers. Each requires its own opening `{` on the
// SAME line as the signature — see the header's "FUNCTION ATTRIBUTION"
// paragraph for the documented limitation and why it does not affect either
// real copy this guard was written against.
// - `function NAME(...) {` — top-level OR nested, any indentation.
const FUNCTION_DECL_RE = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/;
// - `const NAME = (...): ReturnType => {` — block-bodied arrow assigned to
// a const. `[^)]*` and `[^=]*` are bounded, single-purpose character
// classes (no nested/overlapping quantifiers): the former stops at the
// parameter list's closing paren, the latter at the arrow itself.
const ARROW_CONST_RE = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*(?::\s*[^=]+)?=>\s*\{/;
// ADR-3180 Decision 4(d): a guard's scan surface is every AUTHORED surface
// that can EXPRESS the derivation, not `src/` — reading "whole-repo scan" as
// "the whole `src/` tree" is itself the forbidden allowlist, one directory
// wide. This derivation is expressed in TWO languages: TypeScript under
// `src/` (the ladder + `stateExtractField(` shape PASS 1/2 above detect), and
// PROSE in the workflow/command/agent/skill markdown that ships to every
// runtime — `msd-core/workflows/smart-entry.md`'s "Extract: `status`
// (frontmatter `status:` or body `**Status:**`)" is exactly this chain,
// hand-described rather than called. `SCAN_DIRS` therefore covers both;
// `findPromptFieldDrift` (below `findStateFieldDrift`) is the markdown-side
// detector, dispatched by extension in `scanRepo`'s `onFile`. Mirrors
// `lint-planning-prompt-drift.cjs`'s own prompt-layer surface exactly
// (`msd-core/workflows`, `commands`, `agents`, `skills`).
const SCAN_DIRS = ['src', 'msd-core/workflows', 'commands', 'agents', 'skills'];
const SCAN_EXT = new Set(['.cts', '.ts', '.mts', '.md']);
// The designated owner (issue #3187 Phase 5, ADR-3180 §7.7).
const OWNER_FILE = path.join('src', 'state-document.cts');
// Per ADR-3180 Decision 4(a): function-scoped, NEVER a bare file allowlist —
// Decision 4(d) records that a whole-file exemption on the owner is
// precisely how `getMilestoneInfo` stayed invisible to an earlier guard.
// Only `stateFieldValue` itself is exempt: it IS the canonical
// frontmatter-scalar-then-body-field chain (holds the ladder AND calls
// `stateExtractField(` by construction, in its own body — that is its whole
// job), not a copy of it. Every OTHER function in this same file — including
// any future re-derivation added anywhere else in state-document.cts — is
// still scanned and still flagged; nothing else in this file is exempt.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([[OWNER_FILE, new Set(['stateFieldValue'])]]);
/**
* Tokenize the WHOLE file into TWO parallel per-line views, from ONE
* single-pass, escape-aware character scan (not a regex — no backtracking
* cost to bound):
* - `detect[i]`: comments stripped, but string/template literal contents
* KEPT VERBATIM (quotes included). This is what the detection regexes
* (LADDER_RE / STATE_EXTRACT_FIELD_CALL_RE / FUNCTION_DECL_RE /
* ARROW_CONST_RE) run against — they need the literal quoted text
* `'number'` / `'boolean'` to still be present.
* - `braces[i]`: comments AND string/template literal CONTENTS stripped,
* used only for brace-depth counting, so a brace character written
* inside a string (e.g. `{ label: '{' }`) never perturbs the count.
*
* This REPLACES two earlier, narrower designs in turn:
* 1. Two independent per-line helpers (`stripComments` + a separate
* `stripStringLiterals`), each with no cross-line state. That design
* missed a `/* ... *\/`-style block comment whose CLOSING line also
* carries trailing real code (e.g. a `catch { /* comment` opener
* followed by ` * more comment. *\/ }` on a later line): the old
* per-line `stripComments` heuristic ("a line whose trimmed text starts
* with `*` is entirely comment") blanked that closing line WHOLESALE,
* silently dropping the real `}` it also carried, which left a
* function's brace-depth frame permanently open and made every LATER
* `stateExtractField(` call in the file — inside unrelated functions —
* inherit that stale frame's ladder-bearing status (`src/state.cts`'s
* `cmdStateValidate`, which has no ladder of its own, was falsely
* flagged this way).
* 2. A single merged output that dropped string CONTENTS unconditionally.
* That fixed (1) but broke detection outright: LADDER_RE needs the
* literal text `'number'`/`'boolean'` (with quotes) to match, and a
* merged output that strips quote contents for brace-safety also
* erases the very tokens the ladder regex looks for — every ladder
* line silently stopped matching. Two views, not one, is what lets
* each consumer see what it actually needs.
*
* `inBlockComment` and `inTemplate` are threaded ACROSS lines so a block
* comment or a multi-line backtick template literal spanning several source
* lines is tracked correctly regardless of what trails its closing
* delimiter. Regex literals are NOT specially recognised (documented,
* narrow, known limitation, same as the sibling drift guards'
* `readRegexLiteralAt`-free scans): a `/` is only ever treated as a comment
* opener when immediately followed by another `/` or `*`, so an ordinary
* regex literal's slashes pass through as plain characters and any brace
* inside one (e.g. `/x{2,3}/`) is counted like any other character —
* verified harmless for every regex literal actually present in `src/`
* today because each is balanced (equal opens/closes) on its own line, so it
* never desyncs the running depth total even though the individual
* characters are not "understood" as a literal.
*/
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); // template contents kept verbatim for detect
if (inTemplate) {
detect[li] = outDetect;
braces[li] = ''; // whole line still inside the unterminated template
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; // rest of line is a line comment
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; // escape consumes the next character, whatever it is
continue;
}
if (line[j] === quote) {
j++;
break;
}
j++;
}
outDetect += line.slice(start, j); // string kept verbatim for detect
// (nothing appended to outBraces — string contents excluded from depth counting)
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); // rest of line kept verbatim for detect
inTemplate = true;
i = line.length;
break;
}
outDetect += line.slice(start, j); // template kept verbatim for detect
i = j;
continue;
}
outDetect += ch;
outBraces += ch;
i++;
}
detect[li] = outDetect;
braces[li] = outBraces;
}
return { detect, braces };
}
/**
* Pure: find every unsanctioned STATE.md field-extraction fallback-chain
* re-derivation in `text`. `relPath` is the repo-relative path, used both to
* report file:line and to apply the function-scoped owner exemptions above.
*
* Two passes over the same line array:
* PASS 1 walks the file once, maintaining a brace-depth stack of open
* named function frames, and records which function NAMES are
* ladder-bearing (transitively — see the header's "TRANSITIVE ladder
* attribution" paragraph) and, per source line, which frame is
* INNERMOST at that line (for call attribution) — {@link buildFunctionInfo}.
* PASS 2 walks the lines again; any `stateExtractField(` call whose
* innermost enclosing function is ladder-bearing (and not
* function-scoped-exempt) is a violation.
* Returns [{ line, found }].
*/
function buildFunctionInfo(lines) {
const { detect, braces } = scanCode(lines);
const ladderBearing = new Set();
const innermostAt = new Array(lines.length).fill(null);
const stack = []; // { name, openDepth }
let depth = 0;
// A `function NAME(` match whose own line carries no `{` (a multi-line
// signature — parameters and/or a return-type annotation spilling onto
// later lines before the body opens) — awaiting the line that actually
// carries its opening brace. See the header's "FUNCTION ATTRIBUTION"
// paragraph and this function's own doc comment for why this cannot be
// pushed onto `stack` immediately: pushing it with `openDepth` recorded
// BEFORE its own `{` is counted produces a frame whose `openDepth` is the
// ENCLOSING scope's depth, not its own — for a top-level function that is
// `openDepth: 0`, and since real code never reaches negative depth, a
// frame pushed with `openDepth: 0` can NEVER pop (`depth < 0` never
// fires): it would sit at the bottom of `stack` for the rest of the file,
// and every subsequent line with no OTHER open frame would be
// misattributed to it. `ARROW_CONST_RE` never needs this deferral — its
// pattern requires `=>\s*\{` literally, so it only ever matches on a line
// that already carries the brace.
let pendingDeclName = null;
// LADDER WINDOW: a rolling buffer of every `typeof OPERAND === 'TIER'`
// clause seen in the last LADDER_WINDOW_LINES lines, oldest-first. On
// each line, new clauses from that line are appended, then entries older
// than the window are pruned from the front. A ladder is confirmed the
// moment the SAME operand has accumulated both a 'number' and a
// 'boolean' entry inside the buffer — order-independent, and regardless
// of whether the two clauses came from one `||` chain split across
// lines, one single-line chain, or two entirely separate `if`
// statements. See LADDER_WINDOW_LINES's own comment for why this is
// bounded small and is NOT the same concept as the (unbounded,
// function-scoped) ladder-to-`stateExtractField(` co-occurrence below.
const recentClauses = []; // { line, operand, tier }
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);
// A NEW `function NAME(` match replaces any still-pending name
// rather than stacking deferrals — this scanner tracks at most one
// pending declaration at a time (two `function` keywords on
// consecutive lines with neither closing its signature first is not
// a shape that occurs in this codebase's style).
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) {
// First line whose brace count actually increases — its `{` is
// counted in THIS line's `opens`, so `depth` here correctly
// reflects "inside the function", matching every other frame's
// convention (push AFTER updating depth for the pushing line).
stack.push({ name: pendingDeclName, openDepth: depth });
pendingDeclName = null;
} else if (detectCode.includes(';')) {
// The statement terminated before any `{` appeared — a type-only
// declaration, an ambient `declare function`, or an overload
// signature, none of which open a function body. Abandon the
// pending name rather than stranding it to match a `{` that
// belongs to unrelated later code. (A `;` inside a string literal
// on this line would also trigger this — accepted, narrow, known
// limitation: a default-parameter string containing `;` on the
// SAME line as an unterminated multi-line signature has not been
// observed in this codebase.)
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;
if (detectCode.trim()) {
TYPEOF_TIER_CLAUSE_RE.lastIndex = 0;
let clauseMatch;
while ((clauseMatch = TYPEOF_TIER_CLAUSE_RE.exec(detectCode)) !== null) {
recentClauses.push({ line: i, operand: clauseMatch[1], tier: clauseMatch[2] });
}
}
// Prune clauses that have fallen outside the small bounded ladder window.
while (recentClauses.length > 0 && recentClauses[0].line <= i - LADDER_WINDOW_LINES) {
recentClauses.shift();
}
let ladderConfirmed = false;
if (recentClauses.length > 0) {
const tiersByOperand = new Map();
for (const clause of recentClauses) {
let tiers = tiersByOperand.get(clause.operand);
if (!tiers) {
tiers = new Set();
tiersByOperand.set(clause.operand, tiers);
}
tiers.add(clause.tier);
}
for (const tiers of tiersByOperand.values()) {
if (tiers.has('number') && tiers.has('boolean')) {
ladderConfirmed = true;
break;
}
}
}
if (ladderConfirmed) {
// Transitive: every frame currently open (not just the innermost) is
// ladder-bearing, because a nested closure's body is lexically part of
// every one of its enclosing functions' own bodies.
for (const frame of stack) ladderBearing.add(frame.name);
}
}
return { ladderBearing, innermostAt, detect };
}
// ─── PROMPT-LAYER PROSE DETECTION (ADR-3180 Decision 4(d)) ─────────────────
//
// The SAME #1760 fallback chain — "prefer the frontmatter scalar, else fall
// back to the body field" — expressed as MARKDOWN PROSE describing the
// derivation to an agent, rather than TypeScript re-deriving it. Detection is
// intentionally narrow, mirroring `lint-planning-prompt-drift.cjs`'s own
// precedent: a markdown line is a prose re-derivation only when it carries
// ALL FOUR, on the SAME line:
// (a) a backtick-quoted YAML-style frontmatter key token — a lowercase
// identifier immediately followed by a colon, inside backticks, e.g.
// `` `status:` ``. `FRONTMATTER_KEY_TOKEN_RE`.
// (b) a backtick-quoted Markdown BOLD body-field token, e.g.
// `` `**Status:**` ``. `BODY_BOLD_TOKEN_RE`.
// (c) the word "frontmatter" (case-insensitive, word-bounded) anywhere on
// the line.
// (d) the word "body" (case-insensitive, word-bounded) anywhere on the
// line.
// (c) and (d) are what distinguish a line DESCRIBING an fm-then-body
// PRECEDENCE from a line that merely happens to carry two backtick-quoted
// tokens shaped like (a) and (b) for unrelated reasons — both words are
// required so an incidental co-occurrence (e.g. a table row naming an
// unrelated frontmatter key and, several columns over, an unrelated bold
// body label) cannot false-positive on tokens alone.
// All four regexes are small, bounded, single fixed character classes with
// no nested/overlapping quantifiers — nothing for a backtracking engine to
// explore (`npm run lint:ci` runs CodeQL js/redos over this repo, the same
// discipline `findStateFieldDrift`'s own regexes document above).
const FRONTMATTER_KEY_TOKEN_RE = /`[a-z][a-z0-9_]*:`/;
const BODY_BOLD_TOKEN_RE = /`\*\*[^*`]{1,80}\*\*`/;
const FRONTMATTER_WORD_RE = /\bfrontmatter\b/i;
const BODY_WORD_RE = /\bbody\b/i;
// Per ADR-3180 Decision 4(d)/(e): `msd-core/workflows/smart-entry.md`'s
// Fallback step 1 ("`msd-tools` itself is broken") is a PERMANENT, by-
// construction exemption — NOT debt with an owner, and therefore NOT
// modelled as `lint-planning-prompt-drift.cjs`'s shrink-only ratchet
// baseline (which exists specifically to acknowledge sites with a removal
// issue, per Decision 4(e)). This site can never be migrated onto
// `src/state-document.cjs`'s `stateFieldValue`: the surrounding step exists
// PRECISELY for when the CLI itself cannot run (`Cannot find module ...` /
// Node crash — probed one line above this one), so by construction it cannot
// call the canonical owner it is standing in for. Fabricating a "removal
// issue" for something that can never be removed would misrepresent it as
// ratchetable debt it is not.
//
// Keyed on (file, TRIMMED source text) — never a line number, which churns
// on any unrelated edit to the same file — mirroring the ratchet's own key
// shape (`lint-planning-prompt-drift.cjs`) even though this exemption is not
// itself a ratchet. `file` uses the SAME native-separator relPath shape as
// `OWNER_FILE`/`FUNCTION_SCOPED_EXEMPTIONS` above (this guard does not
// POSIX-normalize elsewhere, so this exemption does not either, for
// consistency within the one file).
const PROMPT_LAYER_EXEMPTIONS = new Map([
[
path.join('msd-core', 'workflows', 'smart-entry.md'),
new Map([
[
"- Read `.planning/STATE.md` (frontmatter + body) with the Read tool. Extract: `status` (frontmatter `status:` or body `**Status:**`), `Phase:` from the body, `total_phases`/`percent` from a nested `progress:` frontmatter object if present, and any `## Blockers` items.",
'msd-tools-down fallback (smart-entry.md Fallback step 1): runs only when msd-tools itself cannot run, so it cannot call the canonical owner it substitutes for — permanent by construction, not removable debt.',
],
]),
],
]);
/**
* Pure: find every prose re-derivation of the STATE.md field-extraction
* fallback chain in a markdown file's `text`. Returns `[{ line, found }]` for
* every UNEXEMPTED match — `relPath` (native-separator, matching
* `PROMPT_LAYER_EXEMPTIONS`'s keys) is consulted only for the exemption
* lookup, exactly as `findStateFieldDrift` consults it for
* `FUNCTION_SCOPED_EXEMPTIONS`.
*/
function findPromptFieldDrift(text, relPath) {
const out = [];
const lines = text.split('\n');
const exemptTexts = PROMPT_LAYER_EXEMPTIONS.get(relPath) || null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!FRONTMATTER_KEY_TOKEN_RE.test(line)) continue;
if (!BODY_BOLD_TOKEN_RE.test(line)) continue;
if (!FRONTMATTER_WORD_RE.test(line)) continue;
if (!BODY_WORD_RE.test(line)) continue;
const trimmed = line.trim();
if (exemptTexts && exemptTexts.has(trimmed)) continue;
out.push({ line: i + 1, found: trimmed.slice(0, MAX_REGEX_LITERAL_LEN) });
}
return out;
}
function findStateFieldDrift(text, relPath) {
const out = [];
const lines = text.split('\n');
const exemptFunctions = FUNCTION_SCOPED_EXEMPTIONS.get(relPath) || null;
const { ladderBearing, innermostAt, detect } = buildFunctionInfo(lines);
for (let i = 0; i < lines.length; i++) {
const detectCode = detect[i];
if (!detectCode.trim()) continue;
if (!STATE_EXTRACT_FIELD_CALL_RE.test(detectCode)) continue;
const fn = innermostAt[i];
if (!fn || !ladderBearing.has(fn)) continue;
if (exemptFunctions && exemptFunctions.has(fn)) continue;
out.push({ line: i + 1, found: lines[i].trim().slice(0, MAX_REGEX_LITERAL_LEN) });
}
return out;
}
/**
* Scan the authored source tree (TypeScript under `src/`, prose in the
* prompt-layer markdown — ADR-3180 Decision 4(d)) and return every
* unsanctioned re-derivation, each annotated with the repo-relative file
* path. Dispatches by extension: `.md` files run the prose detector
* (`findPromptFieldDrift`), everything else (`.cts`/`.ts`/`.mts`) runs the
* code detector (`findStateFieldDrift`) — the two derivations are expressed
* in different languages and need different detection shapes over the same
* scan surface.
*/
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/PROMPT_LAYER_EXEMPTIONS above, also keyed
// on `rel` — match consistently regardless of which symlink reached
// the file.
const finder = path.extname(rel) === '.md' ? findPromptFieldDrift : findStateFieldDrift;
return finder(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 state-field-drift: no unsanctioned STATE.md field-extraction fallback-chain re-derivations found\n');
return;
}
process.stderr.write('state-field-drift: independent re-derivation(s) of the STATE.md frontmatter-else-body field\n');
process.stderr.write('fallback chain found. Route these call sites through src/state-document.cjs\n');
process.stderr.write('`stateFieldValue` (issue #3187, ADR-3180 §7.7) instead of re-deriving the ladder:\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 = {
findStateFieldDrift,
findPromptFieldDrift,
buildFunctionInfo,
scanRepo,
OPERAND_SOURCE,
TYPEOF_TIER_CLAUSE_RE,
LADDER_WINDOW_LINES,
STATE_EXTRACT_FIELD_CALL_RE,
FUNCTION_DECL_RE,
ARROW_CONST_RE,
OWNER_FILE,
FUNCTION_SCOPED_EXEMPTIONS,
FRONTMATTER_KEY_TOKEN_RE,
BODY_BOLD_TOKEN_RE,
PROMPT_LAYER_EXEMPTIONS,
SCAN_DIRS,
SCAN_EXT,
scanCode,
MAX_REGEX_LITERAL_LEN,
};