Skip to content

Commit 9ba9e65

Browse files
committed
test(security): pin engine parity with a differential suite
Every defect in this module has been a silent semantic divergence rather than a crash, and each was found by comparing engines over a corpus in a throwaway script. That comparison now lives in the suite: ~700 pattern/document pairs plus match/index/ignore-case parity, checked against the built-in engine on every run, with the accepted divergences enumerated and individually pinned. It earned its place immediately by falsifying my own documentation. The "self-overlapping delimiter" caveat was stale — fixing the boundary-advance in the previous commit also made `(?=#{1,6}\s)`, `(?=aa)` and `(?=##)` exact, so the entry asserting they still diverge failed. One real divergence remains and is now stated precisely: a lookbehind whose body self-overlaps (`(?<=aa)` over `aaaaa`). Making that exact means restarting the scan one character past each match start, which is quadratic on a multi-megabyte document and forfeits the linear guarantee — so it is a deliberate trade, not an oversight. Mutation-verified against the three bugs that actually escaped review: disabling the `\s` translation fails 12 tests, changing the boundary-advance fails 2, and removing the top-level-alternation guard fails 3. That last one initially passed, because the corpus had no alternation pattern in it — the gap is closed and the mutation now fails as it should.
1 parent 76d896d commit 9ba9e65

2 files changed

Lines changed: 249 additions & 5 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Differential test: every pattern the linear engine accepts must behave like
5+
* the built-in engine.
6+
*
7+
* Every defect found in this module has been a silent semantic divergence, not
8+
* a crash — `\s` losing Unicode whitespace, a decomposed lookaround binding to
9+
* the wrong alternation branch, a consumed lookahead swallowing boundaries.
10+
* Each was caught by comparing engines over a corpus, and each would have
11+
* shipped otherwise, because the code was self-consistent and every
12+
* hand-written example passed.
13+
*
14+
* So the comparison lives here rather than in a scratch script. Any future
15+
* change to `translateToRe2` or the split decomposition is checked against
16+
* `RegExp` across the corpus below, with the known divergences enumerated
17+
* explicitly — a divergence that is not on that list is a bug.
18+
*/
19+
20+
import { describe, expect, it } from 'vitest'
21+
import { compileLinearRegex, compileLookaroundSplit } from '@/lib/core/security/linear-regex'
22+
23+
/** How a caller compiles: linear engine first, split decomposition second. */
24+
function compile(pattern: string) {
25+
return compileLinearRegex(pattern) ?? compileLookaroundSplit(pattern)
26+
}
27+
28+
/**
29+
* Delimiters people actually write, plus the shapes that have broken before.
30+
* Anything here that the linear engine accepts must split like `RegExp`.
31+
*/
32+
const SPLIT_PATTERNS = [
33+
// Plain delimiters
34+
'\\n\\n',
35+
'\\n\\n+',
36+
'\\s+',
37+
'\\s{2,}',
38+
'\\.\\s+',
39+
'[.!?]\\s+',
40+
'---+',
41+
'\\|',
42+
',\\s*',
43+
'\\t',
44+
// Structural
45+
'\\n#{1,6}\\s',
46+
'</section>',
47+
'<br\\s*/?>',
48+
'\\r?\\n',
49+
// Lookaround: keep the delimiter
50+
'(?=#\\s)',
51+
'(?=#{1,6}\\s)',
52+
'(?<=\\.)',
53+
'(?<=</s>)',
54+
'(?<=\\.)\\s+',
55+
'(?<=[.!?])\\s+',
56+
'(?<=[.!?])\\s+(?=[A-Z])',
57+
'(?<=\\w)\\s+(?=[A-Z])',
58+
'(?<=\\w)\\s+(?=\\w)',
59+
'\\n(?=Chapter )',
60+
'\\n(?=\\d+\\.)',
61+
'(?<=;)\\s*',
62+
// Top-level alternation beside an assertion: must be declined, never
63+
// reshaped — `(?<=X)A|B` is not `(?:X)(A|B)`.
64+
'(?<=\\.)\\s+|\\n\\n',
65+
'(?<=</p>)|<hr>',
66+
'a|b(?=c)',
67+
'\\n\\n|(?<=\\.)\\s',
68+
// Quantifier and class shapes
69+
'[-=]{3,}',
70+
'\\s*\\n\\s*\\n\\s*',
71+
'(?:\\r\\n|\\n){2}',
72+
'[\\s\\u00b7]+',
73+
]
74+
75+
/**
76+
* Documents chosen to exercise the divergences that have bitten: non-ASCII
77+
* whitespace from PDF/HTML extraction, CJK, and adjacent delimiters.
78+
*/
79+
const DOCUMENTS = [
80+
'Section one\n\nSection two\n\nSection three',
81+
'One. Two. Three. Four.',
82+
'A B C D',
83+
'Heading\n\nAlpha beta.\nGamma delta.',
84+
'# One\nalpha\n## Two\nbeta\n### Three',
85+
'<s>one</s><s>two</s><s>three</s>',
86+
'Chapter 1\nintro\nChapter 2\nmore',
87+
// Non-breaking and exotic whitespace — the stored-data divergence
88+
'Section 1. Overview The agreement',
89+
'Bullet one  Bullet two  Bullet three',
90+
'Prix : 100 EUR. Livraison offerte.',
91+
'第一章 概要 第二章 詳細',
92+
'line
separator
paragraph',
93+
'tab\tseparated\tvalues',
94+
// Degenerate
95+
'',
96+
' ',
97+
'\n\n\n',
98+
'nodelimiterhere',
99+
'a,b,',
100+
'trailing delimiter.\n\n',
101+
'Heading\n\nAlpha beta.\nGamma delta.\n\nMore.',
102+
'one</p>two<hr>three',
103+
'zabzabz',
104+
]
105+
106+
/**
107+
* Divergences that are known, documented on the API, and accepted.
108+
*
109+
* Keep this list short and specific — it is the honest boundary of the
110+
* guarantee, so every entry needs a reason, not just a pattern.
111+
*/
112+
const KNOWN_DIVERGENCES: Array<{ pattern: string; doc: string; reason: string }> = [
113+
{
114+
pattern: '(?<=aa)',
115+
doc: 'aaaaa',
116+
reason:
117+
'Lookbehind whose body self-overlaps. Matching every position would mean restarting the scan one character past each match start, which is quadratic on a multi-megabyte document and forfeits the linear guarantee this module exists for. Delimiters that do not self-overlap — punctuation, tags, whitespace — are exact.',
118+
},
119+
]
120+
121+
function isKnownDivergence(pattern: string): boolean {
122+
return KNOWN_DIVERGENCES.some((entry) => entry.pattern === pattern)
123+
}
124+
125+
/**
126+
* `LinearRegex.split` omits the trailing empty segment `RegExp` produces, and
127+
* every caller filters empties anyway — so compare on the filtered forms.
128+
*/
129+
function normalize(segments: string[]): string[] {
130+
return segments.filter((segment) => segment !== '')
131+
}
132+
133+
describe('differential: split parity with the built-in engine', () => {
134+
const cases = SPLIT_PATTERNS.flatMap((pattern) =>
135+
DOCUMENTS.map((doc) => ({ pattern, doc }))
136+
).filter(({ pattern }) => !isKnownDivergence(pattern))
137+
138+
it(`covers ${cases.length} pattern/document pairs`, () => {
139+
expect(cases.length).toBeGreaterThan(400)
140+
})
141+
142+
it.each(SPLIT_PATTERNS.filter((pattern) => !isKnownDivergence(pattern)))(
143+
'splits %s identically to RegExp across every document',
144+
(pattern) => {
145+
const compiled = compile(pattern)
146+
// A pattern the linear engine declines is handled by the caller (notice,
147+
// literal fallback, or a thrown config error) — not a parity concern.
148+
if (!compiled) return
149+
150+
for (const doc of DOCUMENTS) {
151+
expect(
152+
normalize(compiled.split(doc)),
153+
`pattern ${pattern} on ${JSON.stringify(doc)}`
154+
).toEqual(normalize(doc.split(new RegExp(pattern, 'g'))))
155+
}
156+
}
157+
)
158+
})
159+
160+
describe('differential: test/find parity with the built-in engine', () => {
161+
/** Grep-style patterns, where `test` and the match index are what matter. */
162+
const MATCH_PATTERNS = [
163+
'timeout',
164+
'ECONNREFUSED',
165+
'status=\\d+',
166+
'status=5\\d\\d',
167+
'^Agent',
168+
'agent$',
169+
'(openai|anthropic)',
170+
'\\bstatus\\b',
171+
'\\d{4}-\\d{2}-\\d{2}',
172+
'[Ee]xception',
173+
'https?://[^\\s"]+',
174+
'\\$\\{[^}]*\\}',
175+
'block_\\d+.*output',
176+
'\\s+$',
177+
'^\\s*\\{.*\\}\\s*$',
178+
'a.*b.*c',
179+
'.*',
180+
'.+',
181+
'sk-[A-Za-z0-9]{20,}',
182+
'rate.?limit',
183+
'\\w+@\\w+\\.\\w+',
184+
'[0-9a-f]{8}-[0-9a-f]{4}',
185+
'\\s',
186+
'\\S+',
187+
]
188+
189+
const HAYSTACKS = [
190+
'Agent 1 called api.openai.com -> status=503 at 2026-01-01',
191+
'request timeout occurred after 30s',
192+
'ECONNREFUSED connecting to db',
193+
'user@example.com signed in',
194+
'sk-abcdefghijklmnopqrstuvwxyz012345',
195+
'value with non-breaking space',
196+
'第一章 概要',
197+
'{ "ok": true }',
198+
'',
199+
' ',
200+
]
201+
202+
it.each(MATCH_PATTERNS)('matches %s identically to RegExp', (pattern) => {
203+
const compiled = compile(pattern)
204+
if (!compiled) return
205+
206+
const oracle = new RegExp(pattern)
207+
for (const text of HAYSTACKS) {
208+
const label = `pattern ${pattern} on ${JSON.stringify(text)}`
209+
expect(compiled.test(text), label).toBe(oracle.test(text))
210+
211+
const match = oracle.exec(text)
212+
expect(compiled.find(text), label).toBe(match ? match.index : -1)
213+
}
214+
})
215+
216+
it.each(MATCH_PATTERNS)('matches %s identically to RegExp when ignoring case', (pattern) => {
217+
const compiled = compileLinearRegex(pattern, { ignoreCase: true })
218+
if (!compiled) return
219+
220+
const oracle = new RegExp(pattern, 'i')
221+
for (const text of HAYSTACKS) {
222+
expect(compiled.test(text), `pattern ${pattern} on ${JSON.stringify(text)}`).toBe(
223+
oracle.test(text)
224+
)
225+
}
226+
})
227+
})
228+
229+
describe('differential: every known divergence is still exactly as documented', () => {
230+
// Pinned so the list cannot rot. If a divergence is silently fixed this
231+
// fails and the entry must be deleted; if one spreads, the parity suites
232+
// above fail. Either way the list stays honest about the real boundary.
233+
it.each(KNOWN_DIVERGENCES)('$pattern still diverges on $doc — $reason', ({ pattern, doc }) => {
234+
const compiled = compile(pattern)
235+
expect(compiled).not.toBeNull()
236+
237+
expect(normalize(compiled?.split(doc) ?? [])).not.toEqual(
238+
normalize(doc.split(new RegExp(pattern, 'g')))
239+
)
240+
})
241+
})

apps/sim/lib/core/security/linear-regex.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,14 @@ function parseSplitShape(pattern: string): SplitShape | null {
250250
* lookahead text doubles as the next boundary's lookbehind would be swallowed
251251
* (`(?<=\w)\s+(?=[A-Z])` over `A B C D` would split only half the gaps).
252252
*
253-
* Known divergence: when delimiters themselves overlap — a single-character
254-
* middle whose matches abut, as in `(?<=\w).(?=\w)` — a match starting behind
255-
* the cursor is dropped instead of emitting the empty segment the built-in
256-
* engine would. Splitters that consume whitespace or punctuation between
257-
* tokens do not overlap and are unaffected.
253+
* Known divergence: a delimiter that self-overlaps — `(?<=aa)` over `aaaaa`,
254+
* or a single-character middle whose matches abut as in `(?<=\w).(?=\w)` —
255+
* yields fewer boundaries than the built-in engine. Matching every position
256+
* would mean restarting the scan one character past each match start, which is
257+
* quadratic on a multi-megabyte document and forfeits the linear guarantee
258+
* this module exists for. Delimiters that do not self-overlap — punctuation,
259+
* tags, whitespace between tokens — are exact, and
260+
* `linear-regex.differential.test.ts` pins that.
258261
*/
259262
export function compileLookaroundSplit(
260263
pattern: string,

0 commit comments

Comments
 (0)