diff --git a/gitnexus/src/core/ingestion/languages/r/linear-regex.ts b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts index 1b3726a6d..2a068a87e 100644 --- a/gitnexus/src/core/ingestion/languages/r/linear-regex.ts +++ b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts @@ -325,15 +325,10 @@ const BRACES = /\{(\d+)(?:(,)(\d*))?\}/y; class Parser { private pos = 0; private depth = 0; - /** `\k` is an identity escape only when the pattern has no named group. */ - private readonly hasNamedGroup: boolean; - constructor(private readonly src: string) { - this.hasNamedGroup = src.includes('(?<'); - } + constructor(private readonly src: string) {} parse(): Node { - if (this.hasNamedGroup) reject('named groups and look-behind are unsupported'); const node = this.parseDisjunction(); if (this.pos < this.src.length) reject('unmatched )'); return node; @@ -414,6 +409,9 @@ class Parser { this.pos++; // ( if (this.peek() === '?') { // `(?:` is a plain group; `(?=`, `(?!`, `(?<` and everything else is unsupported or invalid. + // Only a `(` the parser reaches here opens a group: one inside a character class + // (`[(?<]`) or after a backslash (`\(?<`) is a literal and never gets this far. + if (this.peek(1) === '<') reject('named groups and look-behind are unsupported'); if (this.peek(1) !== ':') reject('look-around and group modifiers are unsupported'); this.pos += 2; } @@ -543,7 +541,7 @@ class Parser { return { code: 0x75 }; } case 'k': - // Identity escape (no named groups exist; `parse` already rejected those). + // Identity escape (no named groups exist; `parseGroup` rejects those). return { code: 0x6b }; default: if (c >= '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.