Phase 3 of #3464, following #3465 and #3466. Those phases cut the exemption
ceiling 305 -> 278 by removing vestigial markers and rewriting real
source-greps behaviorally. This one addresses why the ratchet was weak in the
first place: it counts markers, and markers correlated only loosely with
violations, because the rule's implementation was far narrower than its intent.
Four gaps, each measured against 833 files under tests/ before any code was
written:
A TEXT_METHODS omitted matchAll, split and replace, and never handled the
regex-side form re.test(tracked) / /lit/.test(tracked) where the tracked
value is the ARGUMENT rather than the callee object.
B The extension test was /\.(?:cjs|js|ts)/, which does not match .cts,
.mts or .mjs. Under ADR-457 this repo's production modules live in
src/**/*.cts, so the rule has been structurally blind to the entire
TypeScript source surface since that migration. Highest-value fix here.
C Tracking stopped at one hop, so an intermediate transform
(const b = strip(a); b.match(...)) escaped.
D Variables were tracked by NAME in a flat Set<string>, with no scope
resolution, so a name reused across describe/test blocks was conflated.
D removes one verified false positive where an outer `const src =
readFileSync(...)` was cross-attributed to a shadowed arrow-function parameter
of the same name.
Deliberately NOT implemented: flagging reads whose path cannot be statically
resolved. Measured at 4255 sites across 255 files, 66 of them newly red, with a
6-of-6 false-positive rate in spot-checking -- every sampled site read a
markdown workflow or fixture doc through a path variable, not JS source. The
heuristic "no JS extension literal present" inverts to "not a source file" in
this codebase. Shipping it would have manufactured exactly the marker-spam
dynamic this epic exists to stop. The principled version needs real static
resolution (constant-folding path.join and template literals) and is left to a
future phase.
The widening surfaced two genuinely-invisible violations, handled on their
merits rather than uniformly:
tests/verifier-behavior-unverified.test.cjs read src/verification.cts and
regexed it for the VERIFIER_STATUSES array. Fixed BEHAVIORALLY with no
marker: that constant is already exported, so the test now asserts the real
runtime value -- strictly stronger, and immune to source formatting.
Mutation-checked: injecting present_behavior_unverified into the exported
array turns it red, restoring turns it green.
tests/adr-index-gate.test.cjs scans src/plan-drift-guard.cts for
docs/adr/<name>.md citations and asserts each cited ADR exists. Those
citations live in COMMENTS, erased at compile time: no exported value, no
runtime observable, and making it behavioral would mean contorting production
code into exporting its own documentation citations. Irreducible, so it takes
one marker, cited to #3502, stating exactly why. Documenting a real exemption
beats leaving the violation invisible, which was the status quo.
Two defects in this branch's own work, both found by review and fixed here
rather than shipped:
FALSE POSITIVE (adversarial review). Hop propagation walked every Identifier
in a declarator init and treated any reference to a tracked variable as
derivation, regardless of whether the derived VALUE still carried source
text. So `const len = raw.length; /^\d+$/.test(len)` was reported as a
source-grep. Propagation is now value-shape aware: it follows identity,
string-returning string methods, split/join, template embedding, string
concatenation, conditional branches and call arguments; it stops at .length,
numeric methods (indexOf/search/charCodeAt), boolean methods
(includes/startsWith/test), comparisons, negation, typeof, and
Number/parseInt/Boolean coercions. Unrecognized shapes still propagate --
the conservative default for a linter is a rarer false positive over a silent
false negative, and that choice is documented inline. Seven RuleTester rows
now cover this axis, which was previously untested.
SUPER-QUADRATIC SCAN (security review). resolveVariable() resolved each
identifier with two linear Array.find passes over scope.references and
scope.variables, once per identifier walked -- O(vars-in-scope) per lookup.
On a synthetic single-scope file of N consts each referencing ~20 priors:
4.76s at N=3000 and 24.32s at N=6000 (~5.1x for 2x N). Replaced with a
Map<IdentifierNode, Variable> built once per file, lazily, from the scope
manager: 0.19s and 0.37s for the same inputs (~1.95x for 2x N, linear).
Semantics unchanged. No measurable effect on the real repo either way, but
this is exactly the bug class ADR-3212 / local/no-unbounded-quantifier
exists to catch, and this repo has a prior incident where a rule written to
catch complexity bugs shipped with one of its own.
The per-widening measurement had a flaw worth recording: each widening was
measured in isolation, so a site needing TWO at once appeared in neither
column, and the UNION column was dominated by the rejected dynamic-path noise
and never inspected for interactions. adr-index-gate needs both B (.cts) and A
(.matchAll) and was missed for exactly that reason. Real newly-red count was 2,
not the 1 predicted. Corrected on #3502 rather than quietly amended.
Marker-bearing files 277 -> 278 against an unchanged ceiling of 278. The
ceiling is NOT raised: the sole addition is the cited irreducible exemption.
27 RuleTester rows cover the change. The valid rows carry as much weight as the
invalid ones -- shadowed same-name bindings, sibling block scopes, .md and
.json literal reads, dynamic path variables, non-textual derivations, reads
never text-searched, and require() of a .cjs must all stay valid. A widening
that flagged those would be worse than the status quo, because it would push
contributors toward adding markers to silence noise.
Known limit, documented rather than papered over: a tracked value round-tripped
through an array or object literal and read back via destructuring is still not
tracked. Pre-existing, not introduced here, and deliberately not widened for --
closing it means tracking member identity, with its own false-positive surface.
Closes#3502
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>