Files
msd-core/eslint-rules/no-adhoc-regex-escape.cjs
Tom Boucher dc3c81e93d chore(#3212): src/pattern.cts is the sole owner of runtime-value regex construction — Phase 1 (#3416)
* test(#3412): failing-first suite for the pattern-construction seam

Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.

Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.

* chore(#3412): src/pattern.cts owns runtime-value regex construction

Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.

The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.

Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.

RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.

Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.

Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.

* fix(#3412): close the _SOURCE guard evasion, correct two false claims

Three findings from the orthogonal review pass, all fixed.

1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
   name matching with no binding check, so `new RegExp(userInput_SOURCE)`
   — a function parameter — sailed past the guard. That is the same
   rename-evasion class issue #3410 documents, reopened by the very
   fallback meant to complement the structural check. Now bound to the
   identifier's actual binding kind: import, require-derived const, or
   module-scope const; parameters, `let`/`var`, and unresolvable
   bindings fail closed. Four RuleTester cases cover the evasion and
   prove the legitimate cross-module case still passes.

2. src/pattern.cts's own header carried the stale pre-correction counts
   (12 copies / 17 call sites) while CONTEXT.md and the design doc
   carried the corrected ones (~39 / ~44) — a self-contradiction inside
   the PR whose entire purpose is deleting divergent copies. Rewritten,
   preserving the durable lesson: a named-function census cannot see
   inline copies; only a shape-matching guard can.

3. The claim that all deleted copies threw TypeError on non-string was
   false. phase-id.cts's copy — the one with 8 external importers — did
   String(value).replace(...) and never threw. The seam's locked
   signature does not coerce, so this is a real, now-disclosed behavior
   change rather than the pure preservation the tests asserted. Audited
   all 32 invocations across the 8 importers and 6 in-file callers:
   every one is safe by construction (upstream truthy guard or a
   string-producing derivation), verified by runtime probe against the
   compiled modules rather than by TS compilation, which cannot see a
   runtime undefined. Corrected the false claim in both the test comment
   and the design doc, and added it to Known limits.

* docs(#3412): add Changed changeset for the Node 24 floor

The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.

* fix(#3412): resolve the seam's require graph in script fixtures and packaging

Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:

1. ~82 failures — tests/adr-index-gate.test.cjs and
   tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
   mkdtemp fixture and spawn it there (necessary: those scripts resolve
   their scan root from __dirname/.., so running the real script would
   scan the real repo). Each harness hand-listed the dependencies to
   copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
   gen-adr-index.cjs made both lists silently incomplete ->
   MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
   failures from the same crash.

   Fixed as a class, not an instance: new tests/helpers/copy-script-
   fixture.cjs walks a script's transitive static relative-require graph
   and copies it, so dependencies are derived and never re-declared. It
   throws (naming the unbuilt artifact) instead of letting the child die
   with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
   scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
   contract, sync-runtime-launcher.

2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
   the new scripts/lint-no-adhoc-regex-escape.cjs would be
   MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
   the tarball, matching the existing precedent for gen-emitted-
   baseline.cjs, which is excluded for the identical reason, and locked
   with a test modeled on that one. Confirmed against a real npm pack:
   890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
   present (so the other four scripts' requires are legitimate).

3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
   source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
   the retired hand-rolled escaper but NOT text-equivalent: it hex-
   escapes the leading character and all hyphens ('0*\x329',
   '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
   decisions across all three real interpolation prefixes, zero
   divergence. Those tests now compile each source into the same heading
   regex src/roadmap.cts's searchPhaseInContent builds and assert what
   matches and what does not, including the 'i'-flag canonicalization
   the hex escape has to preserve. Re-pinning the new literals would
   have rebuilt the same brittleness one layer down. Adds a test for the
   property the escape exists for: a dot in '1.2' must not act as a
   wildcard.

Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.

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

* fix(#3412): refuse to copy a fixture dependency outside the fixture root

copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).

No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.

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

* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract

Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.

HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.

This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.

espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.

MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.

LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.

MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.

MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.

MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.

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

* chore(#3412): backfill changeset pr number to 3416

* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision

Two CI failures on PR #3416, both in code this branch added.

CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.

Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.

Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.

Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.

Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.

Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:19:57 -04:00

443 lines
19 KiB
JavaScript

'use strict';
/**
* no-adhoc-regex-escape
*
* ADR-3212 (lexical-seam-consolidation) §7 / #3412 Phase 1:
* `src/pattern.cts` is the single seam for regex-metacharacter escaping
* (`escapeRegex` / `literalPattern`, delegating to the built-in
* `RegExp.escape`). This rule flags the two ways a 13th hand-rolled copy of
* that concern can be reintroduced:
*
* 1. ADHOC-REPLACE — an inline `<expr>.replace(<escape-all-metachars
* char-class regex>, '\$&')` call anywhere outside
* `src/pattern.cts`. Matches on the character-class
* MEMBER SET (the 14 regex metacharacters
* `. * + ? ^ $ { } ( ) | [ ] \`), not on exact byte
* order or escaping style — a reordered or
* differently-escaped class with the same member set
* is the same shape and still fires (design doc
* row 25).
*
* 2. UNSAFE-NEW-REGEXP — `new RegExp(<identifier>)` where `<identifier>`
* is a genuinely dynamic, unescaped runtime value
* (a function parameter, or a `let`/`var` binding, or
* a `const` whose initializer is not a static
* string/template) with no seam routing.
*
* Negative space (design doc "Not-corruption"; test-matrix rows 26-28) —
* deliberately NOT flagged:
*
* - `src/pattern.cts` itself — the seam owns this shape.
* - A regex literal that merely CONTAINS the escape-class characters as
* DATA (e.g. `/^[.*+?]$/`) with no `.replace(..., '\$&')` shape present.
* - `new RegExp(<identifier>)` where `<identifier>` is a REVIEWED PATTERN
* FRAGMENT. Provenance is accepted under EITHER of two conditions
* (strongest first):
* (a) STRUCTURAL — the identifier resolves (via scope walk) to a
* `const` declaration whose initializer is a string Literal or a
* TemplateLiteral with zero `${...}` expressions. This cannot be
* evaded by renaming the identifier (#3410's guard-evasion class:
* a rule keyed only on an identifier's NAME is defeated by a
* rename; a rule keyed on the const/static-initializer SHAPE is
* not).
* (b) NAMING CONVENTION (fallback) — the identifier ends in `_SOURCE`
* AND resolves (via scope walk) to one of: an ES `import` binding,
* or a `require(...)`-derived module-scope `const` (either
* `const { X_SOURCE } = require('./mod.cjs')` or
* `const X_SOURCE = require('./mod.cjs').X_SOURCE`). Kept for the
* cross-module case where the initializer itself is not in scope
* to inspect (condition (a) cannot see into another file), e.g. an
* imported `PHASE_NUMBER_TOKEN_SOURCE`. (a) is the real check; (b)
* only covers what (a) structurally cannot see — and is bound to
* the identifier's ACTUAL BINDING KIND, not just its spelling: a
* function parameter, a `let`/`var`, a reassigned binding, or an
* unresolvable identifier is NEVER exempted by naming alone, no
* matter what it's called. A naming-only check (no scope/binding
* verification) is exactly #3410's guard-evasion class — a rule
* keyed on spelling is defeated by picking a matching name for a
* genuinely dynamic value (e.g. a function parameter named
* `userInput_SOURCE`). If scope analysis cannot resolve the
* identifier's binding at all, this fails CLOSED (flags it) —
* an unresolvable binding is not evidence of safety.
* A `new RegExp(<CallExpression>)` argument that already routes through
* the seam (`escapeRegex(...)` / `literalPattern(...)`, by name or as a
* `pattern.escapeRegex(...)` member call) is also treated as safe — that
* IS the fix, not a violation.
*
* Per-finding exemption: add // allow-adhoc-regex-escape: <reason> as a
* trailing comment on the same source line, OR as a standalone comment on
* the line immediately preceding the flagged node (mirrors
* no-adhoc-markdown-parsing's `// allow-adhoc-markdown:` convention).
*/
// The 14 regex metacharacters the census copies' char class escapes.
const ESCAPE_ALL_METACHARS_SET = new Set(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\']);
// The self-reference replacement string every copy uses: '\$&' (3 chars:
// backslash, dollar, ampersand).
const SELF_REFERENCE_REPLACEMENT = '\\$&';
const SOURCE_SUFFIX_RE = /_SOURCE$/;
/**
* Parse the first top-level (unescaped) `[...]` character class out of a
* regex source string and return its member-character set, or `null` if no
* class is present / it is unterminated. Single-pass, escape-aware — mirrors
* the style of no-adhoc-markdown-parsing's hasQualifyingNegatedPipeClass
* (walk once, honor `\` escapes, never backtrack into the class body).
*/
function parseCharClassMembers(src) {
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '[') break;
i += 1;
}
if (i >= src.length) return null;
let j = i + 1;
let negated = false;
if (src[j] === '^') {
negated = true;
j += 1;
}
const members = new Set();
while (j < src.length) {
const c = src[j];
if (c === '\\') {
const next = src[j + 1];
if (next === undefined) return null; // unterminated escape
members.add(next);
j += 2;
continue;
}
if (c === ']') {
return { members, negated };
}
members.add(c);
j += 1;
}
return null; // unterminated class
}
/**
* Does `src` (a regex literal's `.pattern`) contain, as its first char
* class, EXACTLY the escape-all-metachars member set — regardless of
* ordering or which members are backslash-escaped (row 25: shape, not
* bytes)?
*/
function isEscapeAllMetacharsClassSource(src) {
const parsed = parseCharClassMembers(src || '');
if (!parsed || parsed.negated) return false;
if (parsed.members.size !== ESCAPE_ALL_METACHARS_SET.size) return false;
for (const m of ESCAPE_ALL_METACHARS_SET) {
if (!parsed.members.has(m)) return false;
}
return true;
}
/** Is `node` the regex literal `.replace()` first-argument shape? */
function isEscapeAllMetacharsRegexLiteral(node) {
return node.type === 'Literal' && !!node.regex && isEscapeAllMetacharsClassSource(node.regex.pattern);
}
/** Is `node` the string literal `'\$&'` self-reference replacement? */
function isSelfReferenceReplacement(node) {
return node.type === 'Literal' && typeof node.value === 'string' && node.value === SELF_REFERENCE_REPLACEMENT;
}
/**
* Walk up the scope chain from `scope` looking for `identifierName`'s
* declaration. Returns `{ def, kind }` where `kind` is 'param' for a
* function-parameter binding, 'import' for an ES `import` binding, or the
* VariableDeclaration `kind` ('const'/'let'/'var') for a variable binding —
* or `null` if unresolved in any enclosing scope reachable from here (fails
* closed: callers must treat `null` as "not provably safe").
*/
function resolveBinding(identifierName, scope) {
let s = scope;
while (s) {
const variable = s.variables.find((v) => v.name === identifierName);
if (variable) {
const def = variable.defs && variable.defs[0];
if (!def) return null;
if (def.type === 'Parameter') return { def, kind: 'param' };
if (def.type === 'ImportBinding') return { def, kind: 'import' };
if (
def.node
&& def.node.type === 'VariableDeclarator'
&& def.parent
&& def.parent.type === 'VariableDeclaration'
) {
return { def, kind: def.parent.kind };
}
return null;
}
s = s.upper;
}
return null;
}
/** Is `node` a `require(...)` call expression? */
function isRequireCallExpression(node) {
return !!node && node.type === 'CallExpression'
&& node.callee && node.callee.type === 'Identifier' && node.callee.name === 'require';
}
/**
* Provenance condition (b), require branch — does `identifierName` resolve
* to a module-scope `const` whose initializer is EITHER `require(...)`
* itself (the `const { X_SOURCE } = require('./mod.cjs')` destructure shape)
* OR a non-computed member access on a `require(...)` call (the
* `const X_SOURCE = require('./mod.cjs').X_SOURCE` shape)?
*/
function resolvesToRequireDerivedConstBinding(identifierName, scope) {
const binding = resolveBinding(identifierName, scope);
if (!binding || binding.kind !== 'const') return false;
const init = binding.def.node.init;
if (!init) return false;
if (isRequireCallExpression(init)) return true;
if (init.type === 'MemberExpression' && !init.computed && isRequireCallExpression(init.object)) return true;
return false;
}
/** Provenance condition (b), import branch — does `identifierName` resolve to an ES `import` binding? */
function resolvesToImportBinding(identifierName, scope) {
const binding = resolveBinding(identifierName, scope);
return !!binding && binding.kind === 'import';
}
/**
* Provenance condition (a) — STRUCTURAL: does `identifierName` resolve to a
* `const` whose initializer is a static string Literal or a TemplateLiteral
* with no `${...}` expressions?
*/
function resolvesToStaticConstFragment(identifierName, scope) {
const binding = resolveBinding(identifierName, scope);
if (!binding || binding.kind !== 'const') return false;
const init = binding.def.node.init;
if (!init) return false;
if (init.type === 'Literal' && typeof init.value === 'string') return true;
if (init.type === 'TemplateLiteral' && (!init.expressions || init.expressions.length === 0)) return true;
return false;
}
/**
* UNSAFE-NEW-REGEXP is deliberately narrow. An earlier version of this rule
* flagged ANY `new RegExp(<identifier>)` whose identifier resolved to a
* function parameter, a `let`/`var`, or a `const` with a non-static
* initializer — e.g. `const src = someHelper(x); new RegExp(src);`. A first
* whole-repo run proved that heuristic unusable: it fired ~25 times across
* `tests/**` on code with no relation to the escape-helper concern —
* `for (const level of [...]) { ...new RegExp(level)... }` (a fixed literal
* array), `patterns.filter(p => new RegExp(p).test(...))` (`p` is itself an
* already-regex-shaped source string, not literal text needing escaping),
* `const src = phaseId.phaseMarkdownRegexSource(x); new RegExp(src)` (a
* value produced by a function that ALREADY routes through escapeRegex
* internally, just not through a literal `new RegExp(escapeRegex(...))`
* one-liner). A syntax-only rule cannot distinguish "raw literal text that
* needed escaping and didn't get it" from "an already-valid regex-source
* string produced elsewhere" for an arbitrary non-literal expression — so it
* does not try to for the general case.
*
* What IS a reliable, false-positive-free signal is the row-29 shape
* itself: a function whose ENTIRE body is `return new RegExp(<own
* parameter>)` (or an arrow function's implicit-return equivalent) — i.e. a
* hand-rolled re-implementation of `literalPattern()` that skips the
* escaping step. That shape cannot arise from "value already computed
* upstream and merely re-used" (there is no upstream — the parameter IS the
* function's only input), so it is exactly ADR §7's "new RegExp() built
* from a non-literal without routing through the seam" with no other
* plausible reading.
*/
function isSoleReturnOfOwnParameter(newExprNode, argIdentifier) {
let fn = null;
const parent = newExprNode.parent;
if (!parent) return false;
if (parent.type === 'ReturnStatement' && parent.argument === newExprNode) {
const block = parent.parent;
if (
block
&& block.type === 'BlockStatement'
&& block.body.length === 1
&& block.parent
&& (block.parent.type === 'FunctionDeclaration'
|| block.parent.type === 'FunctionExpression'
|| block.parent.type === 'ArrowFunctionExpression')
&& block.parent.body === block
) {
fn = block.parent;
}
}
else if (parent.type === 'ArrowFunctionExpression' && parent.body === newExprNode) {
fn = parent;
}
if (!fn) return false;
return (fn.params || []).some((p) => p.type === 'Identifier' && p.name === argIdentifier.name);
}
function isReviewedPatternFragmentIdentifier(identifierName, scope) {
if (resolvesToStaticConstFragment(identifierName, scope)) return true; // (a) structural
if (SOURCE_SUFFIX_RE.test(identifierName)) {
// (b) naming fallback — only trusted when the identifier's ACTUAL
// BINDING is an import or a require()-derived module-scope const.
// A function parameter, a let/var, a reassigned binding, or an
// unresolvable identifier is never exempted by spelling alone (#3410
// guard-evasion class) — fails closed via resolveBinding returning null.
if (resolvesToImportBinding(identifierName, scope)) return true;
if (resolvesToRequireDerivedConstBinding(identifierName, scope)) return true;
return false;
}
return false;
}
/** Does `node` route through the seam (`escapeRegex(...)` / `literalPattern(...)`)? */
function isSeamRoutedCall(node) {
if (node.type !== 'CallExpression') return false;
const callee = node.callee;
if (!callee) return false;
if (callee.type === 'Identifier') {
return callee.name === 'escapeRegex' || callee.name === 'literalPattern';
}
if (callee.type === 'MemberExpression' && !callee.computed && callee.property) {
return callee.property.name === 'escapeRegex' || callee.property.name === 'literalPattern';
}
return false;
}
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Disallow a hand-rolled regex-metacharacter-escape helper or an unrouted new RegExp() from a runtime value outside src/pattern.cts — import escapeRegex()/literalPattern() from the seam instead.',
category: 'Best Practices',
},
schema: [],
messages: {
adhocRegexEscape:
'Ad-hoc regex-metacharacter-escape detected (.replace() against the escape-all-metachars character class, with the \'\\$&\' self-reference replacement). Import escapeRegex() from ./pattern (src/pattern.cts) instead. Suppress with: // allow-adhoc-regex-escape: <reason>',
unsafeNewRegExp:
'new RegExp() built from a runtime value with no escaping and no seam routing. Route it through escapeRegex()/literalPattern() from ./pattern (src/pattern.cts) first. Suppress with: // allow-adhoc-regex-escape: <reason>',
},
},
create(context) {
const filename = context.getFilename ? context.getFilename() : context.filename;
const normalizedFilename = filename.replace(/\\/g, '/');
// The seam owns this shape — it is not a violation of itself.
if (/(?:^|\/)src\/pattern\.cts$/.test(normalizedFilename)) {
return {};
}
const sourceCode = context.getSourceCode ? context.getSourceCode() : context.sourceCode;
function isAllowed(node) {
const nodeStartLine = node.loc.start.line;
const allComments = sourceCode.getAllComments();
return allComments.some((c) => {
if (!/allow-adhoc-regex-escape:\s*\S/.test(c.value)) return false;
return c.loc.start.line === nodeStartLine || c.loc.start.line === nodeStartLine - 1;
});
}
return {
// ── ADHOC-REPLACE: <expr>.replace(<escape-class>, '\$&') ──────────────
CallExpression(node) {
const callee = node.callee;
if (callee && callee.type === 'MemberExpression' && !callee.computed
&& callee.property && callee.property.name === 'replace') {
const args = node.arguments || [];
const patternArg = args[0];
const replacementArg = args[1];
if (
patternArg
&& replacementArg
&& isEscapeAllMetacharsRegexLiteral(patternArg)
&& isSelfReferenceReplacement(replacementArg)
) {
if (!isAllowed(node)) {
context.report({ node, messageId: 'adhocRegexEscape' });
}
}
return;
}
// ── UNSAFE-NEW-REGEXP is checked on NewExpression below; nothing
// else to do for other CallExpressions here.
},
// ── UNSAFE-NEW-REGEXP: new RegExp(<unrouted runtime identifier>) ──────
NewExpression(node) {
if (!node.callee || node.callee.type !== 'Identifier' || node.callee.name !== 'RegExp') return;
const arg = node.arguments && node.arguments[0];
if (!arg) return;
// Static text (string literal / no-interpolation template literal) —
// fully known at lint time, not a runtime value. Never flagged.
if (arg.type === 'Literal' && typeof arg.value === 'string') return;
if (arg.type === 'TemplateLiteral' && (!arg.expressions || arg.expressions.length === 0)) return;
// Already routed through the seam — that IS the fix.
if (isSeamRoutedCall(arg)) return;
if (arg.type === 'Identifier') {
const scope = context.getScope ? context.getScope() : sourceCode.getScope(node);
if (isReviewedPatternFragmentIdentifier(arg.name, scope)) {
return;
}
// The `_SOURCE` suffix exists ONLY to claim provenance exemption
// (b). An identifier that carries the suffix but did NOT resolve
// to a legitimate import/require-derived binding above (or is
// unresolvable at all) IS the #3410-class evasion this check
// exists to close — flag it unconditionally, independent of the
// narrower sole-return-of-parameter shape below. This cannot
// reproduce the ~25 unrelated false positives an earlier, broader
// "any non-literal identifier" heuristic produced (see
// isSoleReturnOfOwnParameter's doc comment): those identifiers
// never carried the `_SOURCE` naming convention in the first
// place, so this branch never reaches them.
if (SOURCE_SUFFIX_RE.test(arg.name)) {
if (!isAllowed(node)) {
context.report({ node, messageId: 'unsafeNewRegExp' });
}
return;
}
// Narrow, false-positive-free shape only — see
// isSoleReturnOfOwnParameter's doc comment for why the broader
// "any non-literal identifier" heuristic was rejected.
if (isSoleReturnOfOwnParameter(node, arg)) {
if (!isAllowed(node)) {
context.report({ node, messageId: 'unsafeNewRegExp' });
}
}
}
},
};
},
};
// Exposed for reuse by scripts/lint-no-adhoc-regex-escape.cjs (the whole-tree
// parity backstop for directories/extensions this rule's eslint.config.mjs
// wiring does not reach, e.g. tests/fixtures/**/*.cts). ESLint itself only
// reads `.create`/`.meta` off a required rule module, so these extra
// properties are inert to the linter and exist purely as a single source of
// truth for the shape-detection logic (avoids a second, drifting copy of the
// character-class parser — the exact class of bug this rule exists to end).
rule.isEscapeAllMetacharsClassSource = isEscapeAllMetacharsClassSource;
rule.SELF_REFERENCE_REPLACEMENT = SELF_REFERENCE_REPLACEMENT;
module.exports = rule;