From 7980dba6ce429f32a3c702ddbb43eda0cd225860 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Thu, 8 Oct 2026 17:57:48 +0100 Subject: [PATCH] fix(r): reject only a real (?< group in exportPattern, not one inside a character class The linear-regex parser refused any pattern whose text contained "(?<", so [(?<] and \(?= '1' && c <= '9') reject('back-reference or legacy octal escape'); diff --git a/gitnexus/test/unit/r-export-pattern-safety.test.ts b/gitnexus/test/unit/r-export-pattern-safety.test.ts index b811d8d4f..53b9433a6 100644 --- a/gitnexus/test/unit/r-export-pattern-safety.test.ts +++ b/gitnexus/test/unit/r-export-pattern-safety.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { compileRExportPattern } from '../../src/core/ingestion/languages/r/export-pattern.js'; import { compileLinearRegex, + compileLinearRegexDetailed, MAX_STATES, } from '../../src/core/ingestion/languages/r/linear-regex.js'; @@ -733,6 +734,48 @@ describe('compileRExportPattern is linear-time on catastrophic patterns', () => } }); + describe('"(?<" is only a group opener outside a character class and an escape', () => { + it.each([ + ['[(?<]', ['(', '?', '<'], ['a', '']], + ['[^(?<]x', ['ax'], ['(x', ' { + const regex = compileLinearRegex(pattern); + expect(regex, pattern).not.toBeNull(); + for (const name of matching) expect(regex?.test(name), `${pattern} ~ ${name}`).toBe(true); + for (const name of notMatching) { + expect(regex?.test(name), `${pattern} !~ ${name}`).toBe(false); + } + }); + + it.each([ + ['a real look-behind', '(?<=x)y'], + ['a negative look-behind', '(?a)'], + ['after a closed class', '[ab](?<=x)'], + ['after a class holding an escaped ]', '[\\]](?<=x)'], + ['after an escaped [', '\\[(?<=x)'], + ['after a class that holds (?<', '[(?<](?<=x)'], + ['inside a group', '(a|(?<=x)b)'], + ])('still rejects %s', (_label, pattern) => { + expect(compileLinearRegex(pattern), pattern).toBeNull(); + }); + + it('reports a named group or look-behind as such', () => { + for (const pattern of ['(?<=x)y', '(?a)']) { + const result = compileLinearRegexDetailed(pattern); + expect(result.regex).toBeNull(); + expect(result.reason).toContain('look-behind'); + } + }); + }); + it('bounds the matcher by pattern size, not by the name it is run against', () => { // The state count is fixed at compile time and capped, so the work per tested name // is at most name length x MAX_STATES whatever the pattern says.