Files
msd-core/eslint-rules/no-source-grep.cjs
Tom Boucher 2131fe13f3 enhance(#3464): exec() detection widening, citation-debt cleanup — Phase 8 (#4171)
* feat(#3464): widen no-source-grep to detect regex.exec() on tracked text

Adds an execCall kind alongside the existing regexTest detection --
regex.exec(tracked) was invisible to the rule while regex.test(tracked)
was already caught, despite both reading a source-derived string through
a regex. Measured: 4 previously-invisible sites across 2 files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#3464): migrate 4 sites newly flagged by the exec() widening

docs-hooks-table-parity.test.cjs's three regex-extraction loops are
site-scoped marked (source-text-is-the-product) -- the dynamic
preToolEvent/postToolEvent dialect branching they mirror is explicitly
documented as not statically parseable, so a literal-pattern mirror is
the practical minimum-cost check.

no-bare-gsd-tools-command-position.test.cjs's readRouterVerbs() now
requires HOST_COMMAND_ROUTERS directly instead of regex-walking
gsd-tools.cjs's source text -- the same accessor three other suites
already use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3464): pay down 6 grandfathered uncited allow-test-rule markers

Two were genuinely load-bearing (suppressing a real detected violation)
and just needed a citation added -- phase6-capstone-conformance.test.cjs,
runtime-name-policy.test.cjs, both now (#3464).

Four were dead-weight file-header markers suppressing nothing -- each
file's real effective sites are covered by separate, already-cited
markers elsewhere in the same file. Deleted outright rather than cited,
per Phase 1's own precedent (remove non-load-bearing markers instead of
grandfathering them forever) -- codex-config.test.cjs (two copies),
gsd-check-update-worker-platform-gate.test.cjs, orphaned-hooks.test.cjs,
settings-jsonc.test.cjs.

allowlist.json: 134 -> 128 entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3464): re-baseline effective-exemption ceiling to 84

The exec() widening's 3 newly-marked sites are now suppressed and
counted; ceiling rises 81 -> 84, the exact measured high-water mark.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3464): correct citation and restore a wrongly-deleted marker

Two review corrections, both found by the orthogonal review pass:

- docs-hooks-table-parity.test.cjs's 3 new exec() markers cited #3464
  (mechanically "the phase that widened the rule") when the file's own
  established, correct reference is #3839 (the issue this whole test
  exists to enforce, already cited in its file header) -- fixed to match.

- gsd-check-update-worker-platform-gate.test.cjs's deleted file-header
  marker was NOT dead weight: its codeOnly() helper wraps readFileSync
  and is called inline as an assert argument, a genuine source-grep
  pattern on real .cjs/.js source that the rule cannot currently see
  (helper-function indirection is a distinct blind spot from anything
  Phase 7/8 measured) -- CONTRIBUTING.md is explicit that "unverified"
  is not the same as "vestigial." Restored, site-scoped this time
  (directly above codeOnly(), not as an inert file-header comment) and
  cited (#3103, the issue the file's own docstring already references).

codex-config.test.cjs's two deletions and orphaned-hooks.test.cjs's /
settings-jsonc.test.cjs's deletions were independently re-verified and
stand: their flagged lines read generated .toml/.json OUTPUT, not
source, or have no residual pattern at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 08:11:23 -04:00

753 lines
32 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)` /
* `regex.exec(tracked)` / `/lit/.exec(tracked)` (#3464 phase 8).
*
* 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).
*
* A `readFileSync(...)` path argument that is a bare Identifier is resolved
* ONE hop back to its `VariableDeclarator` initializer before classification,
* so a path built once and passed by reference is recognized the same as an
* inline path expression; `hooks` is also a recognized source directory
* alongside `bin`/`lib`/`gsd-core`/`src` (#3545 / epic #3464 phase 7).
*/
// 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() / regex.exec()) 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;
}
// WIDENING (fold, #3545/epic #3464 phase 7): resolve a bare Identifier
// path argument back to its variable initializer, ONE hop, before
// text-matching it -- so `const p = path.join(__dirname, '..', 'src',
// 'x.cjs'); readFileSync(p)` is classified the same as an inline
// `readFileSync(path.join(__dirname, '..', 'src', 'x.cjs'))`. Only
// resolves a `VariableDeclarator`'s `init` (not an `AssignmentExpression`
// — an assignment-bound identifier is a documented, deliberate miss, see
// 40-design.md "Known limits"), and only ONE hop (a chain of two or more
// indirections stays invisible -- also documented).
function resolveOneHopText(node) {
if (node.type !== 'Identifier') return sourceCode.getText(node);
const v = resolveVariable(node);
if (!v) return sourceCode.getText(node);
const def = v.defs.find((d) => d.type === 'Variable' && d.node && d.node.init);
if (!def) return sourceCode.getText(node);
return sourceCode.getText(def.node.init);
}
// 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, hooks, 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 = resolveOneHopText(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 (bin, lib, gsd-core, hooks, src).
const hasSourceDir = /['"](?:bin|lib|gsd-core|hooks|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' });
} else if (propName === 'exec') {
pendingCalls.push({ node, kind: 'execCall' });
}
},
'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' / 'execCall': re.test(tracked) or
// /lit/.test(tracked), and identically re.exec(tracked) or
// /lit/.exec(tracked) (#3464 phase 8) -- both return a
// regex-shaped result, but what matters here is only that the
// ARGUMENT (not the callee object) may carry the tracked source
// text, so the receiver/argument classification is shared
// byte-for-byte between the two kinds.
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;