From b946051a46385299927638c56d58daa6b858eded Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 14 Aug 2026 13:51:33 -0400 Subject: [PATCH] fix(#3498): escapeRegex falls back below Node 24 (no RegExp.escape) (#3499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegExp.escape is ES2026 (first shipped in Node 24); pattern.cts called it unconditionally, and the build consumes the module (scripts/gen-loop-host-contract.cjs), so npm run build failed on Node 22 and the gsd-test linux-node22 lane could not reach run_tests. Fix: prefer the built-in when present, else an in-file metachar escape — still the sole owner of escaping (#3212 invariant; lint scope unchanged). Builtin captured at module load so runtime mutation cannot flip the path. Regression: tests/pattern.test.cjs section 4 — child-process probes neuter RegExp.escape before/after require and assert match-behavior equivalence. Co-authored-by: sim --- .changeset/lively-orcas-climb.md | 5 ++++ src/pattern.cts | 26 ++++++++++++++---- tests/pattern.test.cjs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 .changeset/lively-orcas-climb.md diff --git a/.changeset/lively-orcas-climb.md b/.changeset/lively-orcas-climb.md new file mode 100644 index 000000000..a8180a580 --- /dev/null +++ b/.changeset/lively-orcas-climb.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3499 +--- +**The build no longer requires Node 24: `escapeRegex` falls back to an in-file metachar escape when `RegExp.escape` is absent** (#3498) — `RegExp.escape` is ES2026 (Node 24+), and `src/pattern.cts` called it unconditionally, so `npm run build` itself failed on Node 22 (`gen-loop-host-contract` consumes the module), breaking the gsd-test `linux-node22` verification lane. The seam now prefers the built-in when present and falls back otherwise — still the single owner of escaping (#3212 invariant preserved). Behavior on Node 24+ is unchanged; match behavior below Node 24 is verified equivalent by regression tests that neuter `RegExp.escape` in a child process. diff --git a/src/pattern.cts b/src/pattern.cts index de1067fd2..c4b9bd44a 100644 --- a/src/pattern.cts +++ b/src/pattern.cts @@ -6,10 +6,10 @@ * (gitignored), per the repo's ADR-457 build-at-publish convention. * * Sole owner of building a `RegExp` from a runtime value. `escapeRegex` - * delegates to the built-in `RegExp.escape` (ES2026 / Node 24+) rather than - * hand-rolling yet another copy of the metacharacter-escape helper this - * module replaces — see ADR §1 ("No module outside the seam escapes a value - * for regex use"). + * delegates to the built-in `RegExp.escape` (ES2026 / Node 24+) when present, + * falling back to an in-file metacharacter escape below Node 24 (#3498) — + * still the one owner: no module outside this seam escapes a value for regex + * use (ADR §1). * * Counts, corrected during implementation (design doc "Ground truth" #1; * .gsd/phase/chore-3412-pattern-seam/40-design.md): the ADR's census counted @@ -41,8 +41,24 @@ * migration-equivalence property sweep in tests/pattern.test.cjs (rows 15-17). */ +// #3498: RegExp.escape is ES2026 (first shipped in Node 24). The gsd-test +// matrix still runs a linux-node22 lane, and the build itself consumes this +// module (scripts/gen-loop-host-contract.cjs), so a hard dependency breaks +// `npm run build` on Node 22. Prefer the built-in when present; otherwise use +// the local metachar escape — still inside this file, so the #3212 sole-owner +// invariant (and lint-no-adhoc-regex-escape's scope) is preserved. Captured at +// module load so a runtime mutation of RegExp.escape cannot flip the path +// mid-process. +const escapeBuiltin: ((value: string) => string) | undefined = + typeof RegExp.escape === 'function' + ? RegExp.escape.bind(RegExp) + : undefined; + +const escapeMetachars = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + export function escapeRegex(value: string): string { - return RegExp.escape(value); + return (escapeBuiltin ?? escapeMetachars)(value); } export function literalPattern(value: string, flags?: string): RegExp { diff --git a/tests/pattern.test.cjs b/tests/pattern.test.cjs index ccdbd6852..9dc3cb858 100644 --- a/tests/pattern.test.cjs +++ b/tests/pattern.test.cjs @@ -202,3 +202,50 @@ describe('migration equivalence (row-9 sweep)', () => { ); }); }); + +// ─── Section 4: #3498 — Node-22 fallback (no RegExp.escape) ───────────────── + +describe('escapeRegex without RegExp.escape (#3498 Node-22 fallback)', () => { + // `RegExp.escape` is ES2026 (first shipped in Node 24). The gsd-test matrix + // runs a linux-node22 lane and the BUILD consumes this module + // (scripts/gen-loop-host-contract.cjs), so the seam must work when the + // builtin is absent. Simulated in a child process: neuter RegExp.escape + // BEFORE require (module-load capture must select the fallback), then assert + // match-behavior correctness — this file's doctrine (pattern TEXT differs + // between builtin and fallback; match behavior must not). + const { runNode, OUTCOME } = require('./helpers/process-seam.cjs'); + const path = require('node:path'); + const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'pattern.cjs'); + + const PROBE = [ + "RegExp.escape = undefined;", + "const { escapeRegex, literalPattern } = require(" + JSON.stringify(LIB) + ");", + "const corpus = ['a.b*c', '^dollar$', '(group|alt)', '[b]{1,2}', 'back\\\\slash', 'plain', 'q+x?y', 'a-b-c', ''];", + "for (const v of corpus) {", + " if (!new RegExp(escapeRegex(v)).test(v)) { console.error('self-match fail: ' + JSON.stringify(v)); process.exit(1); }", + " if (!literalPattern(v).test(v)) { console.error('literalPattern fail: ' + JSON.stringify(v)); process.exit(1); }", + "}", + "if (new RegExp(escapeRegex('a.b*c')).test('aXbZc')) { console.error('metachar reinterpreted'); process.exit(1); }", + "if (new RegExp(escapeRegex('(a|b)')).test('a')) { console.error('alternation reinterpreted'); process.exit(1); }", + "console.log('fallback-ok');", + ].join('\n'); + + test('builds and escapes correctly when RegExp.escape is absent (Node 22 semantics)', () => { + const r = runNode(['-e', PROBE], { timeoutMs: 30_000 }); + assert.strictEqual(r.outcome, OUTCOME.EXITED, `probe must run: ${r.stderr}`); + assert.strictEqual(r.exitCode, 0, `fallback path failed: ${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /fallback-ok/); + }); + + test('neutering AFTER require must not flip the path mid-process (capture at load)', () => { + const PROBE2 = [ + "const { escapeRegex } = require(" + JSON.stringify(LIB) + ");", + "RegExp.escape = undefined;", + "if (!new RegExp(escapeRegex('a.b')).test('a.b')) { console.error('post-load neuter broke escaping'); process.exit(1); }", + "console.log('capture-ok');", + ].join('\n'); + const r = runNode(['-e', PROBE2], { timeoutMs: 30_000 }); + assert.strictEqual(r.outcome, OUTCOME.EXITED, `probe must run: ${r.stderr}`); + assert.strictEqual(r.exitCode, 0, `post-load capture failed: ${r.stdout}\n${r.stderr}`); + }); +});