Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
425 lines
16 KiB
JavaScript
425 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* RuleTester unit tests for `local/no-adhoc-regex-escape`
|
|
* (#3212 Phase 1, #3412).
|
|
*
|
|
* Design: .msd/phase/chore-3412-pattern-seam/40-design.md (Negative space)
|
|
* Test matrix: .msd/phase/chore-3412-pattern-seam/50-test-matrix.md — section 5, rows 24-29
|
|
* ADR: docs/adr/3212-lexical-seam-consolidation.md §7
|
|
*
|
|
* TDD RED: `eslint-rules/no-adhoc-regex-escape.cjs` does not exist yet —
|
|
* this file's require() throws MODULE_NOT_FOUND until the implementing phase
|
|
* adds it. That is the intended starting state.
|
|
*
|
|
* RuleTester setup (languageOptions, sourceType, filename conventions) mirrors
|
|
* tests/eslint-rules.test.cjs's `no-adhoc-markdown-parsing` suite, which is
|
|
* this repo's other src/*.cts-scoped structural-shape rule.
|
|
*
|
|
* Contract this test file locks for the not-yet-written rule (per ADR §7 and
|
|
* the design doc's Negative space section):
|
|
* - FIRES on an inline `.replace(<escape-all-metachars class>, '\$&')`
|
|
* shape outside src/pattern.cts, matching the SHAPE (character-class
|
|
* membership set) not exact byte order (row 25).
|
|
* - Does NOT fire inside src/pattern.cts itself — the seam is the owner
|
|
* (row 26).
|
|
* - Does NOT fire on `new RegExp(<identifier>)` when the identifier is a
|
|
* `*_SOURCE`-suffixed constant — the design's named provenance marker for
|
|
* "deliberate, reviewed pattern fragments" (row 27).
|
|
* - Does NOT fire on a regex literal that merely contains the escape class
|
|
* as DATA, with no `.replace(..., '\$&')` shape present (row 28).
|
|
* - FIRES on `new RegExp(<identifier>)` when the identifier is a plain
|
|
* runtime value with no `_SOURCE` provenance marker and no routing
|
|
* through escapeRegex/literalPattern — ADR §7's "new RegExp() built from
|
|
* a non-literal without routing through the seam" (row 29).
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { RuleTester } = require('eslint');
|
|
|
|
const noAdhocRegexEscape = require('../eslint-rules/no-adhoc-regex-escape.cjs');
|
|
const { findViolations } = require('../scripts/lint-no-adhoc-regex-escape.cjs');
|
|
|
|
const ruleTester = new RuleTester({
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'commonjs',
|
|
},
|
|
});
|
|
|
|
// Separate instance for the ES-module `import` binding case (row 27b) — the
|
|
// default instance above uses sourceType 'commonjs', which cannot parse
|
|
// `import` statements.
|
|
const moduleRuleTester = new RuleTester({
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'module',
|
|
},
|
|
});
|
|
|
|
describe('no-adhoc-regex-escape rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noAdhocRegexEscape.create, 'function');
|
|
});
|
|
|
|
// ── row 24: invalid — a new inline escape-all-metachars .replace() ────────
|
|
|
|
test('row 24 invalid: a new inline .replace(/[.*+?^${}()|[\\]\\\\]/g, \'\\\\$&\') outside the seam', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// NOT String.raw: the code-under-test contains a literal `${` (part
|
|
// of the escape-all-metachars character class), which String.raw
|
|
// would still interpret as a template-literal substitution marker.
|
|
// A normal (non-raw) template literal is used instead, with `\$`
|
|
// and doubled backslashes to produce the exact literal source text.
|
|
code: `
|
|
function esc(s) {
|
|
return s.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'adhocRegexEscape' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── row 25: invalid — reordered/differently-escaped char class, same shape ─
|
|
|
|
test('row 25 invalid: same escape shape with a REORDERED character class still fires (matches shape, not exact bytes)', () => {
|
|
// Same 14-member set { . * + ? ^ $ { } ( ) | [ ] \ } as row 24, but written
|
|
// in a different order and with a different (still valid) escaping style.
|
|
// If the rule only string-matched the exact literal bytes of the census
|
|
// copies, this would slip through — it must not.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`
|
|
function escapeForRegex(value) {
|
|
return value.replace(/[\^$.|?*+()[\]{}\\]/g, '\\$&');
|
|
}
|
|
`,
|
|
filename: 'src/another-module.cts',
|
|
errors: [{ messageId: 'adhocRegexEscape' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── row 26: valid — the seam file itself is exempt ─────────────────────────
|
|
|
|
test('row 26 valid: src/pattern.cts itself is exempt (the seam owns this shape)', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
// Not String.raw — see the row-24 comment above (`${` needs escaping
|
|
// in a non-raw template literal).
|
|
code: `
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
}
|
|
`,
|
|
filename: 'src/pattern.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── row 27: valid (negative space) — new RegExp from an exported *_SOURCE ──
|
|
|
|
test('row 27 valid: new RegExp built from an exported *_SOURCE constant is NOT flagged (critical negative-space case)', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
// A deliberate, reviewed pattern fragment (design doc Negative space:
|
|
// PHASE_NUMBER_TOKEN_SOURCE, MILESTONE_HEADING_LINE_SOURCE, etc.) —
|
|
// its metacharacters are load-bearing and must not be treated as an
|
|
// unescaped runtime value.
|
|
code: String.raw`
|
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?';
|
|
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
|
|
`,
|
|
filename: 'src/phase-id.cts',
|
|
},
|
|
{
|
|
code: String.raw`
|
|
const MILESTONE_HEADING_LINE_SOURCE = '^##\\s+Milestone\\s';
|
|
const re = new RegExp(MILESTONE_HEADING_LINE_SOURCE, 'm');
|
|
`,
|
|
filename: 'src/roadmap-parser.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── row 28: valid (negative space) — regex literal containing the class as data ─
|
|
|
|
test('row 28 valid: a regex literal that merely CONTAINS [.*+?] as data is NOT flagged', () => {
|
|
// A legitimate validator matching one of the metacharacters as literal
|
|
// DATA — no .replace(..., '\$&') shape present, so this must not fire.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const isMetachar = /^[.*+?]$/.test(char);`,
|
|
filename: 'src/some-validator.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── #3412 Standards-review Finding 1: _SOURCE naming-only evasion closed ──
|
|
// A rule that trusts the `_SOURCE` suffix on spelling alone (no
|
|
// scope/binding check) is exactly #3410's guard-evasion class: naming a
|
|
// genuinely dynamic value with a matching suffix defeats it. These
|
|
// cases lock that the fallback is now bound to the identifier's ACTUAL
|
|
// BINDING KIND (import / require()-derived const), not its name.
|
|
|
|
test('Finding 1 invalid: a function-parameter identifier named *_SOURCE is NOT exempted by naming alone', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// The evasion: naming a plain, unescaped function parameter with
|
|
// the `_SOURCE` suffix used to sail past condition (b) because
|
|
// that check was pure identifier-name regex matching with no
|
|
// scope/binding verification. It must fire now.
|
|
code: String.raw`
|
|
function f(userInput_SOURCE) {
|
|
return new RegExp(userInput_SOURCE);
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('Finding 1 invalid: a reassigned let identifier named *_SOURCE is NOT exempted by naming alone', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// A `let` binding holding genuinely dynamic content (not a
|
|
// static const, not an import, not a require()-derived const) —
|
|
// the suffix alone must not exempt it.
|
|
code: String.raw`
|
|
let mutable_SOURCE = getUserInput();
|
|
const re = new RegExp(mutable_SOURCE);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('Finding 1 valid: a require()-derived *_SOURCE const (destructured) still passes', () => {
|
|
// The legitimate cross-module case condition (b) exists to cover:
|
|
// `const { X_SOURCE } = require('./mod.cjs')`. Proves the tightened
|
|
// check did not regress this repo's dominant CJS import shape.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
code: String.raw`
|
|
const { PHASE_NUMBER_TOKEN_SOURCE } = require('./phase-id.cjs');
|
|
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
|
|
`,
|
|
filename: 'src/roadmap-parser.cts',
|
|
},
|
|
{
|
|
// The other require()-derived shape condition (b) covers:
|
|
// `const X_SOURCE = require('./mod.cjs').X_SOURCE`.
|
|
code: String.raw`
|
|
const PHASE_NUMBER_TOKEN_SOURCE = require('./phase-id.cjs').PHASE_NUMBER_TOKEN_SOURCE;
|
|
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
|
|
`,
|
|
filename: 'src/roadmap-parser.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('Finding 1 valid: an ES `import`-derived *_SOURCE binding still passes', () => {
|
|
moduleRuleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
code: String.raw`
|
|
import { PHASE_NUMBER_TOKEN_SOURCE } from './phase-id.cts';
|
|
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
|
|
`,
|
|
filename: 'src/roadmap-parser.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── row 29: invalid — new RegExp from a genuinely unescaped runtime value ──
|
|
|
|
test('row 29 invalid: new RegExp from a genuinely unescaped runtime value fires', () => {
|
|
// A plain, non-`_SOURCE`-suffixed parameter interpolated straight into
|
|
// new RegExp() with no seam routing (no escapeRegex/literalPattern call
|
|
// anywhere on it) — ADR §7: "flags new RegExp() built from a non-literal
|
|
// without routing through the seam." This is the minimal-pair inverse of
|
|
// row 27: same `new RegExp(<identifier>)` shape, but the identifier
|
|
// carries no `_SOURCE` provenance marker.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`
|
|
function buildLiteralMatcher(userSuppliedValue) {
|
|
return new RegExp(userSuppliedValue);
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── #3951 Rung A: UNSAFE-NEW-REGEXP widened to MemberExpression ───────────
|
|
// Design: .msd/phase/feat-3951-b6-b7-guard-ledger/40-design.md "Rung A".
|
|
// The whole arm used to gate on `arg.type === 'Identifier'`, so a
|
|
// MemberExpression argument (`obj['key']`, `cfg.pattern`) was never
|
|
// examined — the reason this rule never fired on the #3477 ReDoS.
|
|
|
|
test('#3951 invalid: new RegExp(obj[\'key\']) — a computed MemberExpression is now examined', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `const re = new RegExp(obj['key']);`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('#3951 invalid: new RegExp(cfg.pattern) — a non-computed MemberExpression is now examined', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `const re = new RegExp(cfg.pattern);`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('#3951 valid: new RegExp(X.source, flags) — the safe re-flag-composition idiom is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{ code: `const re = new RegExp(existingPattern.source, 'g');`, filename: 'src/some-module.cts' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('#3951 valid: new RegExp(config.pattern.source) — a second .source site is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{ code: `const re = new RegExp(config.pattern.source);`, filename: 'src/some-module.cts' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('#3951 invalid: X.anything (not literally .source) is still flagged — the exemption keys on the PROPERTY only', () => {
|
|
// Guards against the design doc's stated failure mode: exempting on the
|
|
// OBJECT instead of the PROPERTY would wave through `X.anything`.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `const re = new RegExp(existingPattern.anything);`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('#3951 valid: a _SOURCE constant reached through a required module namespace is NOT flagged', () => {
|
|
// tests/continuation-grammar-parity.test.cjs:124,174,374's real shape —
|
|
// `const phaseId = require('../msd-core/bin/lib/phase-id.cjs')`, then
|
|
// `new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE)`. Same provenance-exempt
|
|
// class isReviewedPatternFragmentIdentifier already trusts for bare
|
|
// identifiers, extended to a MemberExpression on a require()-derived
|
|
// module-scope const.
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const phaseId = require('../msd-core/bin/lib/phase-id.cjs');
|
|
const re = new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE);
|
|
`,
|
|
filename: 'tests/continuation-grammar-parity.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('#3951 regression: new RegExp(someIdentifier) still behaves exactly as before (sole-return-of-parameter still fires)', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`
|
|
function buildLiteralMatcher(userSuppliedValue) {
|
|
return new RegExp(userSuppliedValue);
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'unsafeNewRegExp' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('#3951 regression: new RegExp(<identifier already computed upstream>) still NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
|
|
valid: [
|
|
{
|
|
code: String.raw`
|
|
const src = someHelper(x);
|
|
const re = new RegExp(src);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── ReDoS regression — scripts/lint-no-adhoc-regex-escape.cjs's own regex ─
|
|
|
|
test('an adversarial [] run after .replace(/ terminates instead of backtracking exponentially (#3412)', () => {
|
|
// Pre-fix, REPLACE_CALL_RE's outer alternation let a `[...]` run be
|
|
// consumed either by the character-class branch or one char at a time
|
|
// by the catch-all branch, so a failing match explored both parses of
|
|
// every bracket pair: measured n=30 -> 3475ms (~2^n growth per +2). This
|
|
// adversarial input never closes the `.replace(/` call (no trailing
|
|
// `, '...')`), forcing the engine all the way through backtracking on a
|
|
// pre-fix regex. n=2000 is comfortably past where the pre-fix regex
|
|
// would already be unusable; a linear-time regex resolves it instantly.
|
|
const adversarial = '.replace(/' + '[]'.repeat(2000) + 'X';
|
|
const result = findViolations(adversarial);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
});
|