Files
msd-core/eslint-rules/no-source-grep.cjs
Tom Boucher bc557f6876 chore(#3520): ratchet on effective exemptions, track unverified separately (#3529)
Phase 5 of #3464, following #3465, #3466, #3502 and #3508. Those cut the
ceiling 305 -> 278, made the rule accurate, and ended file-wide amnesty. This
one fixes the number itself.

scripts/lint-allow-test-rule-refs.cjs counted FILES CONTAINING MARKER TEXT.
Only 5 of those files carry a marker that actually suppresses a violation the
rule detects, across 10 sites. The ratcheted number was ~98% noise, which is
exactly why bumping it was frictionless: the metric was never coupled to the
thing it claimed to govern. That is the whole complaint this epic opened with,
stated precisely.

Verified directly rather than assumed: only eslint-rules/no-source-grep.cjs
functionally honors the marker. Four other rule files mention allow-test-rule
in prose only, and no-raw-rmsync-in-tests.cjs:24 explicitly states it does not
apply. So the large count was not legitimately large because several rules
share the annotation.

Now two numbers, only the first ratcheted:

  EFFECTIVE EXEMPTIONS -- markers that actually suppress a detected violation.
  10 sites across 5 files. Tightly ratcheted in both directions, as before:
  over the ceiling fails, and slack beyond grace fails.

  UNVERIFIED MARKERS -- marker-bearing files with no detectable violation. 273
  files. Reported and given a loose ceiling so the pool cannot silently
  balloon, but deliberately NOT tightly ratcheted, because shrinking it is a
  rule-coverage problem and not a delete-the-markers problem.

A file with at least one effective site counts as effective and is not also
counted as unverified; the two numbers never double-count.

Reporting ONLY the effective count was considered and rejected. It would say
five files and look excellent while being falsely reassuring, because "no
detectable violation" is not "no violation". This phase's own measurement found
two genuine source-greps that are unsuppressed AND undetected --
tests/security-prompt-injection.security.test.cjs:852 and
tests/check-update-config-dir.test.cjs:91 -- each reading a real shipped file
and text-searching it, invisible only because the path is bound to a separate
const the rule never resolves back to its literal. Markers guarding that class
count as zero-effective and would look vestigial. Trading a number that is too
big and meaningless for one that is too small and falsely reassuring is not
progress, so the script prints the known-limit caveat alongside the numbers and
the two undetected violations are filed separately rather than lost.

Single source of truth is structural, not a matter of discipline. The script
does not re-implement detection or the site-scoped adjacency predicate -- that
is the generative-fix-divergence class this repo has shipped before. The rule
now exports MAX_MARKER_LOOKAHEAD_LINES, MARKER_COMMENT_RE,
collectMarkerAndCommentLines and isSuppressedAt (extracted verbatim, no logic
change), plus a default-off neutralizeSuppression option so the counter can
enumerate every site through the real rule via ESLint's Linter API and then
classify each with the rule's own predicate, replicating reportUnlessSuppressed's
search-line-OR-read-line check exactly. Default rule behavior is byte-identical:
tests/eslint-rules.test.cjs passes 168/168 unchanged. A parity test asserts the
script's suppressed/not verdict equals the rule's own report/no-report outcome
for every site in a fixture corpus.

A real silent-failure bug surfaced and was fixed while building this: ESLint's
flat-config Linter reports "No matching configuration" and returns ZERO messages
for any filename resolving outside its cwd. That would have quietly
misclassified every sandboxed test fixture as having no violations -- a test
suite that passes while asserting nothing. Fixed by anchoring the Linter to the
tests dir, with a defensive throw if it ever recurs.

Two earlier claims of mine are corrected by this phase's measurement. Widening
the source-dir allowlist to include hooks/ -- the "fifth blind spot" recorded in
#3508 -- rescues ZERO sites; it is real in principle and has no practical
effect, because the hooks reads that exist are missed for other reasons
(.sh extension, identifier-indirection, dynamic filenames). And the #3508
correction that attributed those reads to the hooks/ gap rather than to variable
indirection was itself incomplete: both are independently sufficient, so fixing
either alone changes nothing. I accepted the reviewer's causal claim as
uncritically as I had made my own.

The unverified count is 273, not the ~289 in the phase design doc. That is
legitimate drift -- the baseline was measured at fba7c9032 and other merged work
has since removed markers. Left as measured rather than adjusted to match the
document.

Adversarial review found a BLOCKER in the first revision and it is fixed here.
The counter walked only tests/**/*.test.cjs, but the rule is registered on
tests/**/*.cjs -- every .cjs, not just test files -- plus scripts/**,
eslint-rules/**, bin/lib/**, pi/**, examples/**, gsd-core/bin/** and three
plugin globs. 35 non-.test.cjs files under tests/ were never walked, and
tests/helpers/live-command-registry.cjs:1 carries a real marker that appeared in
NEITHER reported number. A counter that undercounts is worse than the
meaningless one it replaces, because it will be trusted.

The scan set is now derived programmatically: the script dynamically imports
eslint.config.mjs and extracts the `files` globs from every config block that
enables local/no-source-grep. There is no hardcoded list to drift, which is the
same divergence class the parity test already guards.

Two further defects surfaced while fixing it. The silent-clean catch around
linter.verify() was worse than reported -- ESLint signals a parse error by
returning a message with fatal:true rather than throwing, so the original catch
would not even have fired for the common case. Both paths now throw with the
file path. That silent swallow was actively hiding a broken fixture in this
suite's own tests: 'no marker here\n' is not valid JS and the case only
"passed" because the parse failure was discarded. Fixed.

And once the scan widened, the raw-text marker scanner started matching this
tooling's own doc comments and RuleTester fixture strings, so marker extraction
moved to AST comment nodes. That corrected three long-standing FALSE POSITIVES:
allowlist entries for tests/eslint-rules.test.cjs that were never real markers,
only fixture payload. Allowlist 137 -> 135: three false positives pruned, one
real entry added for live-command-registry.

The headline numbers are coincidentally unchanged (10/10 effective across 5
files, 273/280 unverified) but the composition is corrected: one real file
gained, one phantom dropped. Verified by direct diff rather than inferred from
the totals matching.

The first remote run of this branch came back RED with 23 failures, all one
cause, and it is fixed here. Classification drove ESLint's flat-config Linter,
which resolves configuration relative to a cwd and refuses to lint any file
outside it. The test harness writes fixtures into an OS temp dir, so every
sandbox row hit "No matching configuration found" and tripped the defensive
throw. It surfaced only on the container because the repo lives at /work there
and the fixtures at /tmp, making the mismatch unmissable; a local run had
reported the suite green, which it was not.

Fixed by not depending on config *resolution* at all: the script now builds an
eslintrc-format Linter and registers the rule directly with defineRule, since it
already knows exactly which rule and options it wants. That removes the
cwd-ancestor constraint entirely and makes repo files and out-of-tree fixtures
classify identically. Verified out-of-tree explicitly, not just in-repo, because
the local temp path shape is what hid the bug the first time. Parse failures
still throw loudly -- that behavior is required and tested. Classifications are
unchanged for real repo files (10/10 effective across 5, 273/280 unverified,
0 live), which is the check that the config swap did not quietly alter results.

The second remote run cut the failures from 23 to 2, and the survivors were a
genuinely different and more interesting defect:
tests/packaging-shipped-scripts-require-only-shipped.test.cjs caught that
scripts/ SHIPS in the published package while eslint-rules/ does not, so
importing the rule for single-source-of-truth would MODULE_NOT_FOUND in a real
consumer's install. That test statically extracts require() calls, so hiding the
import inside a function would have dodged the check without fixing the problem.

Resolved along the grain of existing convention rather than by weakening
anything: package.json already excludes several repo-internal lint gates from
the shipped set via `!scripts/...`, including
`!scripts/lint-no-adhoc-regex-escape.cjs`, which is the same situation. This
gate is CI-only and has no meaning in a consumer install, so it joins them.
Verified with `npm pack --dry-run` that the .cjs is genuinely absent from the
tarball (its inert JSON config files remain, and carry no requires).

The third remote run failed on shard 3/3 across all three OSes with exit code
NULL and empty output -- the spawned gate was killed by a timeout, not failing
an assertion. Cause: replacing a raw text scan with a full ESLint Linter pass
over every file in every registered glob took the gate from 0.57s to ~7-12s,
and GitHub's runners are slower than the bench that had just passed it green.

Fixed by narrowing the work rather than raising the timeout to hide it. Both
numbers the gate computes are properties of files that CONTAIN a marker, so
only those (~294) need linting; the repo-wide byte walk that finds them stays,
since that was the undercount fix. Live violations in files carrying NO marker
are already enforced by npm run lint over exactly these globs, so re-detecting
them here was redundant. 2.87s now, from ~7s measured locally.

That narrowing changes what one reported number means, so the wording changed
with it: "live violations in marker-bearing files: 0 (unmarked files are
enforced separately by npm run lint)". A number that quietly covers less than
it reads is the failure this whole epic is about, so it is stated rather than
left implicit, and the test row that asserted the old broader behavior was
split -- an unmarked live violation now passes this gate (and is caught by
eslint), while a live violation in a marker-bearing file still fails it.

The repo-baseline test's timeout was also raised to 30s with a comment, since a
gate that legitimately takes ~3s must not sit at a timeout close to its own
runtime. Every other row keeps the shorter sandbox-scoped timeout.

Closes #3520

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:33:31 -04:00

720 lines
30 KiB
JavaScript

'use strict';
/**
* no-source-grep
*
* Flags variables bound to readFileSync() of a .cjs/.cts/.js/.mjs/.mts/.ts
* source path that later have a text-search method called on them, whether
* directly, via a bounded chain of derived bindings (`const b = f(a)`,
* `b = a`, ...), or via `regex.test(tracked)` / `/lit/.test(tracked)`.
*
* Variable identity is resolved through real lexical scope (ESLint
* `Variable` objects via `sourceCode.scopeManager`/`getScope`), not by name
* string, so a same-named binding in an unrelated or shadowing scope is
* never conflated with a tracked one.
*
* Honor a SITE-SCOPED escape comment: // allow-test-rule: <reason> (#NNN)
* A marker suppresses only the violation(s) it sits next to (same line, or
* above with nothing but blank/comment lines between), not the whole file
* (#3508 / epic #3464 phase 4). "Next to" is checked against EITHER half of
* the read+search pair -- the text-search call site, or the readFileSync()
* call that originated the tracked value -- so annotating the read directly
* (the intuitive placement) suppresses the violation just as well as
* annotating the search call (adversarial-review fix, epic #3464 phase 4).
*/
// How many derivation hops from the original readFileSync() binding to
// follow before giving up on a transitive chain. hop=1 is the variable
// bound directly to the readFileSync() result; hop=2 is a variable derived
// one step from it; etc. Depth-bounded on purpose (test-matrix.md rows
// 9-11): a chain longer than this is a documented, accepted blind spot, not
// a bug — see 40-design.md "Known limits".
const MAX_TRANSITIVE_HOPS = 3;
// How many source lines a `// allow-test-rule: <reason>` marker is allowed
// to sit above the violation it suppresses (0 = same line as the marker's
// own line, i.e. the line directly below it). The repo's real placement
// style is a marker followed by a short run of CONTINUATION PROSE (more
// `//` comment lines expanding on the reason) immediately before the flagged
// call -- observed spans across the 8 real #3508 violation sites run 0-4
// comment lines (e.g. the #3502 marker in tests/adr-index-gate.test.cjs, the
// #770 markers in tests/install-minimal-hooks.test.cjs). 8 gives that a
// comfortable margin without being effectively unbounded -- large enough to
// never force churn on a legitimately-placed marker, small enough that a
// marker meant for one call site cannot drift into covering an unrelated
// site 40+ lines later (test-matrix.md row 4, the defect this closes).
const MAX_MARKER_LOOKAHEAD_LINES = 8;
// Matches a `allow-test-rule: <reason>` directive inside a comment's VALUE
// (the AST comment node's text with delimiters stripped -- i.e. this is
// tested against `c.value`, never against raw source text). Exported so
// external consumers (scripts/lint-allow-test-rule-refs.cjs) that need to
// find marker comments via the SAME AST-comment definition the rule itself
// honors can import this instead of hand-rolling an equivalent pattern that
// could silently drift from what the rule actually recognizes.
const MARKER_COMMENT_RE = /allow-test-rule:\s*\S/;
/**
* Given every comment in a file (`sourceCode.getAllComments()`), compute the
* two line-level facts `isSuppressedAt` needs: which lines carry a
* `allow-test-rule:` marker, and which lines are wholly comment (used for the
* "nothing but blank/comment lines between the marker and the violation"
* purity check). Extracted verbatim from the per-file computation `create()`
* used to do inline, so a second consumer (the effective-exemption counter)
* can derive the identical inputs without re-deriving the marker-detection
* logic itself.
*
* @param {{value: string, loc: {start: {line:number}, end: {line:number}}}[]} allComments
* @returns {{markerLines: number[], commentLineSet: Set<number>}}
*/
function collectMarkerAndCommentLines(allComments) {
const markerLines = [];
const commentLineSet = new Set();
for (const c of allComments) {
for (let l = c.loc.start.line; l <= c.loc.end.line; l++) {
commentLineSet.add(l);
}
if (MARKER_COMMENT_RE.test(c.value)) {
for (let l = c.loc.start.line; l <= c.loc.end.line; l++) {
markerLines.push(l);
}
}
}
return { markerLines, commentLineSet };
}
/**
* Site-scoped suppression predicate: is `violationLine` suppressed by any of
* `markerLines`? Extracted verbatim from the logic `create()` used to close
* over directly (previously named `isSuppressed`), generalized to take its
* per-file inputs as parameters instead of reading them off closure state, so
* a second consumer (scripts/lint-allow-test-rule-refs.cjs, the
* effective-exemption counter) can call the EXACT SAME adjacency arithmetic
* the rule uses at report time -- rather than reimplementing it, which is the
* generative-fix-divergence defect class this repo has shipped before.
*
* A violation at `violationLine` is suppressed if some marker sits on that
* exact line (trailing form) or on an earlier line within `maxLookahead`,
* with every line strictly between the marker and the violation being blank
* and/or itself a comment line -- i.e. no live code sits between the marker
* and the call it suppresses. This is what makes suppression SITE-scoped
* rather than file-wide.
*
* @param {object} opts
* @param {number[]} opts.markerLines - line numbers carrying a marker.
* @param {number} opts.violationLine - the candidate violation's line.
* @param {Set<number>} opts.commentLineSet - lines wholly occupied by a comment.
* @param {string[]} opts.lines - the file's source lines (sourceCode.lines).
* @param {number} [opts.maxLookahead] - defaults to MAX_MARKER_LOOKAHEAD_LINES.
* @returns {boolean}
*/
function isSuppressedAt({
markerLines,
violationLine,
commentLineSet,
lines,
maxLookahead = MAX_MARKER_LOOKAHEAD_LINES,
}) {
function isBlankLine(line) {
const text = lines[line - 1];
return text !== undefined && text.trim() === '';
}
for (const markerLine of markerLines) {
if (markerLine > violationLine) continue;
if (violationLine - markerLine > maxLookahead) continue;
let pure = true;
for (let l = markerLine + 1; l < violationLine; l++) {
if (!isBlankLine(l) && !commentLineSet.has(l)) {
pure = false;
break;
}
}
if (pure) return true;
}
return false;
}
const TEXT_METHODS = new Set([
'includes',
'match',
'matchAll',
'startsWith',
'endsWith',
'indexOf',
'search',
'split',
'replace',
]);
// Method names that, called ON an already-tracked value, still return a
// value that may carry the source file's TEXT (string in, string/array
// out) -- so tracking continues to propagate through the derived result.
const PROPAGATING_STRING_METHODS = new Set([
'replace',
'replaceAll',
'slice',
'substring',
'substr',
'trim',
'trimStart',
'trimEnd',
'toLowerCase',
'toUpperCase',
'normalize',
'padStart',
'padEnd',
'concat',
'repeat',
'at',
'toString',
'valueOf',
'split',
'join',
]);
// Method names that, called ON an already-tracked value, definitively
// return a non-text (number/boolean) result. Calling one of these directly
// on a tracked value IS itself the violation TEXT_METHODS exists to catch
// (see `includes`/`startsWith`/etc. above) -- but the RETURN VALUE of the
// call must not stay tracked, or `const ok = raw.includes('x'); ok.foo()`
// would go on being treated as if `ok` were still source text.
const NON_PROPAGATING_METHODS = new Set([
'indexOf',
'lastIndexOf',
'search',
'charCodeAt',
'codePointAt',
'localeCompare',
'includes',
'startsWith',
'endsWith',
'test',
]);
// Global "shape-narrowing" functions whose return value is definitively
// not text, regardless of what is passed in.
const NON_PROPAGATING_CALLEE_NAMES = new Set([
'Number',
'parseInt',
'parseFloat',
'Boolean',
]);
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Disallow reading source .cjs/.cts/.js/.mjs/.mts/.ts files with readFileSync and then doing text search on the result',
category: 'Best Practices',
},
// `neutralizeSuppression` is a diagnostic-only knob for
// scripts/lint-allow-test-rule-refs.cjs (the effective-exemption
// counter): when true, every candidate violation is reported regardless
// of a marker, so the script can enumerate the FULL site inventory via
// one real ESLint pass, then classify each site with isSuppressedAt
// (exported below) against the real markers. No config in this repo
// passes this option, so default (real) linting is unaffected -- this is
// an extract-and-export refactor of existing logic, not a behavior
// change to `no-source-grep` itself.
schema: [
{
type: 'object',
properties: {
neutralizeSuppression: { type: 'boolean' },
},
additionalProperties: false,
},
],
messages: {
noSourceGrep:
'Source-grep test: do not read source .cjs/.cts/.js/.mjs/.mts/.ts files with readFileSync and call .includes/.match/.matchAll/.startsWith/.indexOf/.split/.replace/.search (or regex.test()) on the result. Use require() to run the module instead. Add // allow-test-rule: <reason> (#NNN) directly above (or trailing) the flagged line to suppress just that site.',
// Diagnostic-only companion to `noSourceGrep`, emitted ONLY when the
// `neutralizeSuppression` schema option is set (see its doc comment
// and `reportUnlessSuppressed` above) -- never fires with the real
// (shipped) config, so this never appears in real lint output.
noSourceGrepDiagnosticReadLine: '{{readLine}}',
},
},
create(context) {
const sourceCode = context.getSourceCode
? context.getSourceCode()
: context.sourceCode;
// All comments in the file (used both to find markers and to know which
// lines are "just a comment" for the lookahead purity check below).
const allComments = sourceCode.getAllComments();
// Line numbers of every `// allow-test-rule: <reason>` marker comment in
// the file (a marker may span one line or several -- see
// collectMarkerAndCommentLines' doc comment above), and the set of lines
// fully occupied by ANY comment (used by isSuppressedAt's purity check).
const { markerLines, commentLineSet } = collectMarkerAndCommentLines(allComments);
// Diagnostic-only: see the `neutralizeSuppression` schema option doc
// comment above. Never true for any config in this repo.
const neutralizeSuppression = !!(
context.options &&
context.options[0] &&
context.options[0].neutralizeSuppression
);
// A violation at `violationLine` is suppressed if some marker sits on
// that exact line (trailing form) or on an earlier line within
// MAX_MARKER_LOOKAHEAD_LINES, with every line strictly between the
// marker and the violation being blank and/or itself a comment line --
// i.e. no live code (not even the readFileSync() call the marker is
// ostensibly about) sits between the marker and the call it suppresses.
// This is what makes suppression SITE-scoped rather than file-wide: a
// marker parked far above an unrelated later violation (test-matrix.md
// row 4) no longer reaches it. Delegates to the exported isSuppressedAt
// predicate (see its doc comment) rather than duplicating the adjacency
// arithmetic here.
function isSuppressed(violationLine) {
if (neutralizeSuppression) return false;
return isSuppressedAt({
markerLines,
violationLine,
commentLineSet,
lines: sourceCode.lines,
});
}
// Map from Identifier AST node -> resolved ESLint `Variable`, built once
// per file (see buildIdentifierVariableMap) so that resolveVariable() is
// an O(1) lookup instead of a per-call linear scan over scope.references
// / scope.variables. Populated lazily on first use from Program:exit,
// after the scope manager has finished analyzing the whole file, and
// rebuilt fresh for every file since `create(context)` runs per file
// (nothing here is module-level state).
let identifierVariableMap = null;
// Walk every scope exactly once and record, for each Identifier node
// that is either a resolved reference or a declaration site, the
// `Variable` it resolves to. References are indexed first and
// declarations only fill in gaps, mirroring the precedence of the
// original per-call algorithm (which checked scope.references before
// falling back to scope.variables) -- though in practice an Identifier
// node can only ever be one or the other, never both.
function buildIdentifierVariableMap() {
const map = new Map();
const scopeManager = sourceCode.scopeManager;
for (const scope of scopeManager.scopes) {
for (const ref of scope.references) {
if (ref.resolved) map.set(ref.identifier, ref.resolved);
}
}
for (const scope of scopeManager.scopes) {
for (const variable of scope.variables) {
for (const def of variable.defs) {
if (def.name && !map.has(def.name)) map.set(def.name, variable);
}
}
}
return map;
}
// Resolve an Identifier node to the ESLint `Variable` it names, via real
// scope analysis rather than name-string matching. Handles both uses
// (references, resolved through reference.resolved) and declaration
// sites (the `id` of a VariableDeclarator, a parameter, etc.).
function resolveVariable(identifierNode) {
if (!identifierVariableMap) {
identifierVariableMap = buildIdentifierVariableMap();
}
return identifierVariableMap.get(identifierNode) || null;
}
// Detect if a node represents a readFileSync call on a source file
// (.cjs/.cts/.js/.mjs/.mts/.ts) that lives in a source directory
// (bin, lib, gsd-core, src).
function isSourceReadFileSync(node) {
if (!node || node.type !== 'CallExpression') return false;
const callee = node.callee;
const isFsRead =
(callee.type === 'Identifier' && callee.name === 'readFileSync') ||
(callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'readFileSync');
if (!isFsRead) return false;
const args = node.arguments;
if (!args || args.length === 0) return false;
const firstArg = args[0];
const fullSrc = sourceCode.getText(firstArg);
return looksLikeSourcePath(fullSrc);
}
// Given the source text of a path expression, determine if it references
// a .cjs/.cts/.js/.mjs/.mts/.ts source file in a source directory.
function looksLikeSourcePath(src) {
// Must end with a source extension (in a string). Longer extensions
// are listed first in the alternation so `.cts`/`.mts`/`.mjs` are
// never partially matched by the shorter `.js`/`.ts`/`.cjs` arms.
const hasSourceExt = /['"`.][^'"`.]*\.(?:cts|mts|mjs|cjs|js|ts)['"`)]/i.test(src);
if (!hasSourceExt) return false;
// Must reference a source directory indicator somewhere in the expression
const hasSourceDir = /['"](?:bin|lib|gsd-core|src)['"]/i.test(src);
return hasSourceDir;
}
// Variable -> hop number. hop=1 is a variable bound directly to a
// source readFileSync() result; each additional derivation hop
// increments by 1, capped at MAX_TRANSITIVE_HOPS.
const hopOf = new Map();
// Variable -> line number of the readFileSync() call that originated the
// value tracked at that variable (same line as the hop=1 seed for a
// direct binding; propagated unchanged through every derivation hop,
// since a transitive chain is still fundamentally about the SAME
// original read+search pair). Populated in lockstep with hopOf below so
// a report can consult "where was this text actually read from" and
// honor a marker placed at either half of the pair (adversarial-review
// fix: marker adjacent to the read alone must suppress too, not just a
// marker adjacent to the search call).
const readLineOf = new Map();
// Generic conservative fallback: walk every Identifier under `node` and
// return the {hop, line} of the identifier with the smallest hop number
// among identifiers that resolve to an already-tracked variable, or null
// if none do. This is the DEFAULT for any expression shape not
// explicitly recognized below (arguments to an unknown function call,
// logical expressions, etc.) -- for an unrecognized shape we choose to
// PROPAGATE (risking a rarer false positive) rather than silently drop a
// true positive, because the callee/operator may still be returning text
// derived from the tracked value. Clearly-scalar shapes (member access,
// comparisons, numeric/boolean methods, Number()/parseInt()/etc.) are
// special-cased below to explicitly NOT propagate instead, since for
// those we know for certain the result cannot carry text.
function walkForTrackedInfo(node) {
let best = null;
(function walk(n) {
if (!n || typeof n.type !== 'string') return;
if (n.type === 'Identifier') {
const v = resolveVariable(n);
if (v && hopOf.has(v)) {
const h = hopOf.get(v);
if (best === null || h < best.hop) {
best = { hop: h, line: readLineOf.get(v) };
}
}
}
for (const key of Object.keys(n)) {
if (key === 'parent') continue;
const val = n[key];
if (Array.isArray(val)) {
for (const child of val) {
if (child && typeof child.type === 'string') walk(child);
}
} else if (val && typeof val.type === 'string') {
walk(val);
}
}
})(node);
return best;
}
// Determine whether tracking should propagate through `node`'s value
// into whatever it is assigned/bound to, and if so, at what (minimum)
// hop -- and from which original read line -- it draws from. Returns
// null when the value shape is one we know for certain cannot still
// carry the tracked file's text.
function trackedInfo(node) {
if (!node || typeof node.type !== 'string') return null;
switch (node.type) {
case 'Identifier': {
// Identity: `const b = a;`
const v = resolveVariable(node);
return v && hopOf.has(v)
? { hop: hopOf.get(v), line: readLineOf.get(v) }
: null;
}
case 'AwaitExpression':
return trackedInfo(node.argument);
case 'ConditionalExpression': {
// `cond ? a : other` -- only the branches can carry the tracked
// value; the test itself is a boolean and does not propagate.
const c = trackedInfo(node.consequent);
const a = trackedInfo(node.alternate);
if (c === null) return a;
if (a === null) return c;
return c.hop <= a.hop ? c : a;
}
case 'TemplateLiteral': {
// `` `${a}` `` -- a template embedding a tracked value still
// carries its text.
let best = null;
for (const expr of node.expressions) {
const info = trackedInfo(expr);
if (info !== null && (best === null || info.hop < best.hop)) {
best = info;
}
}
return best;
}
case 'BinaryExpression': {
// String concatenation (`a + 'x'` / `'x' + a`) may still carry
// text; every OTHER binary operator (===, !==, ==, !=, <, >, <=,
// >=, arithmetic, etc.) produces a boolean/number and must not
// propagate.
if (node.operator !== '+') return null;
const l = trackedInfo(node.left);
const r = trackedInfo(node.right);
if (l === null) return r;
if (r === null) return l;
return l.hop <= r.hop ? l : r;
}
case 'UnaryExpression':
// `!x`, `typeof x`, `void x`, `-x`, `+x`, `~x` all produce a
// non-text primitive.
return null;
case 'MemberExpression':
// Bare property/element access that is NOT itself a call (e.g.
// `.length`, `.size`, or any other property read). This is the
// reported false-positive shape (`const len = raw.length;`):
// none of these definitively still carry the original text, so
// do not propagate.
return null;
case 'ArrayExpression':
case 'ObjectExpression':
// Do not widen into array/object literals or the destructuring
// that would be needed to read a value back out of them. This is
// a documented, deliberate blind spot (not a new bug) -- see
// 40-design.md "Known limits".
return null;
case 'CallExpression': {
const callee = node.callee;
// Number(...), parseInt(...), parseFloat(...), Boolean(...):
// the result is definitively not text, regardless of the arg.
if (
callee.type === 'Identifier' &&
NON_PROPAGATING_CALLEE_NAMES.has(callee.name)
) {
return null;
}
// Array.isArray(...): definitively boolean.
if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Array' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'isArray'
) {
return null;
}
// Method call on a tracked receiver: `obj.method(...)`. Whether
// the result stays tracked depends on what the method returns.
if (
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier'
) {
const objInfo = trackedInfo(callee.object);
if (objInfo !== null) {
const propName = callee.property.name;
if (NON_PROPAGATING_METHODS.has(propName)) return null;
if (PROPAGATING_STRING_METHODS.has(propName)) return objInfo;
// Unrecognized method name on a known-tracked receiver:
// conservative default for an unrecognized call result (see
// fallback rationale above) -- propagate rather than risk
// silently dropping a true positive.
return objInfo;
}
}
// Not a recognized narrowing/receiver call shape: fall through
// to the generic conservative walk (covers "tracked value passed
// as an argument to any call", e.g. `const b = strip(a);`).
return walkForTrackedInfo(node);
}
default:
// Any other expression shape (LogicalExpression, parenthesized
// expressions -- which are not a distinct AST node -- etc.):
// conservative default, see walkForTrackedInfo doc comment.
return walkForTrackedInfo(node);
}
}
const pendingDeclarators = [];
const pendingAssignments = [];
const pendingCalls = [];
return {
VariableDeclarator(node) {
if (node.id.type === 'Identifier' && node.init) {
pendingDeclarators.push({ id: node.id, init: node.init });
}
},
AssignmentExpression(node) {
if (node.left.type === 'Identifier' && node.right) {
pendingAssignments.push({ left: node.left, right: node.right });
}
},
CallExpression(node) {
if (node.callee.type !== 'MemberExpression') return;
const propName = node.callee.property.name;
if (TEXT_METHODS.has(propName)) {
pendingCalls.push({ node, kind: 'textMethod' });
} else if (propName === 'test') {
pendingCalls.push({ node, kind: 'regexTest' });
}
},
'Program:exit'() {
// Seed hop=1 for variables bound directly to a source readFileSync(),
// recording the readFileSync() call's own line as the "origin read
// line" for that variable.
for (const { id, init } of pendingDeclarators) {
if (isSourceReadFileSync(init)) {
const v = resolveVariable(id);
if (v && !hopOf.has(v)) {
hopOf.set(v, 1);
readLineOf.set(v, init.loc.start.line);
}
}
}
for (const { left, right } of pendingAssignments) {
if (isSourceReadFileSync(right)) {
const v = resolveVariable(left);
if (v && !hopOf.has(v)) {
hopOf.set(v, 1);
readLineOf.set(v, right.loc.start.line);
}
}
}
// Fixpoint over derived bindings, bounded by MAX_TRANSITIVE_HOPS.
// Each variable is added at most once, so this always terminates.
// The origin read line is carried through unchanged from whichever
// parent variable the hop was derived from -- a transitive chain is
// still fundamentally about the same original read+search pair.
let changed = true;
while (changed) {
changed = false;
for (const { id, init } of pendingDeclarators) {
const v = resolveVariable(id);
if (!v || hopOf.has(v)) continue;
const parentInfo = trackedInfo(init);
if (parentInfo !== null && parentInfo.hop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentInfo.hop + 1);
readLineOf.set(v, parentInfo.line);
changed = true;
}
}
for (const { left, right } of pendingAssignments) {
const v = resolveVariable(left);
if (!v || hopOf.has(v)) continue;
const parentInfo = trackedInfo(right);
if (parentInfo !== null && parentInfo.hop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentInfo.hop + 1);
readLineOf.set(v, parentInfo.line);
changed = true;
}
}
}
// Report a violation at `node` unless a marker's site-scoped
// suppression (see isSuppressed above) covers either the search
// call's own line OR the line of the readFileSync() call that
// originated the tracked value (adversarial-review fix: the
// violation is fundamentally about the read+search PAIR, so a
// marker adjacent to either half is a legitimate, still strictly
// site-scoped, way to annotate it). `readLine` is optional -- pass
// it whenever the call site can determine one.
function reportUnlessSuppressed(node, readLine) {
if (neutralizeSuppression) {
// Diagnostic-only mode (see the `neutralizeSuppression` schema
// option doc comment): report every candidate site regardless of
// suppression, PLUS a paired companion message at the exact same
// node carrying `readLine` -- the other half of the read+search
// pair that the real (non-neutralized) suppression check above
// also consults. This lets a consumer (the effective-exemption
// counter) replicate this rule's own OR-of-two-lines suppression
// decision from the OUTSIDE via `isSuppressedAt` without this
// rule re-exposing its internal hop/scope-resolution machinery.
// Never fires with the real (shipped) config.
context.report({ node, messageId: 'noSourceGrep' });
context.report({
node,
messageId: 'noSourceGrepDiagnosticReadLine',
data: { readLine: readLine === undefined || readLine === null ? '' : String(readLine) },
});
return;
}
if (isSuppressed(node.loc.start.line)) return;
if (readLine !== undefined && readLine !== null && isSuppressed(readLine)) {
return;
}
context.report({ node, messageId: 'noSourceGrep' });
}
// Now that hopOf is stable, evaluate every candidate call site.
for (const { node, kind } of pendingCalls) {
const obj = node.callee.object;
if (kind === 'textMethod') {
// varName.includes(...), varName.match(...), etc.
if (obj.type === 'Identifier') {
const v = resolveVariable(obj);
if (v && hopOf.has(v)) {
reportUnlessSuppressed(node, readLineOf.get(v));
continue;
}
}
// Inline: readFileSync(...).includes(...) -- read and search are
// the same line, so no separate read line to pass.
if (isSourceReadFileSync(obj)) {
reportUnlessSuppressed(node);
}
continue;
}
// kind === 'regexTest': re.test(tracked) or /lit/.test(tracked).
// The tracked variable is the ARGUMENT here, not the callee object.
const looksLikeRegexReceiver =
obj.type === 'Identifier' || (obj.type === 'Literal' && !!obj.regex);
if (!looksLikeRegexReceiver) continue;
const args = node.arguments;
if (!args || args.length === 0) continue;
const argInfo = trackedInfo(args[0]);
if (argInfo !== null) {
reportUnlessSuppressed(node, argInfo.line);
}
}
},
};
},
};
module.exports = rule;
// Named exports consumed by scripts/lint-allow-test-rule-refs.cjs (the
// effective-exemption counter) and its tests -- ESLint itself only reads
// `.create`/`.meta` off this module, so these extra properties are inert to
// ESLint and exist purely as the single source of truth for anything that
// needs to reason about marker suppression outside the rule's own
// Program:exit walk. See each function's doc comment above for why this
// extraction exists (generative-fix-divergence prevention).
module.exports.MAX_MARKER_LOOKAHEAD_LINES = MAX_MARKER_LOOKAHEAD_LINES;
module.exports.MARKER_COMMENT_RE = MARKER_COMMENT_RE;
module.exports.collectMarkerAndCommentLines = collectMarkerAndCommentLines;
module.exports.isSuppressedAt = isSuppressedAt;