fix(desktop): regex-literal state in runtime-loader codeRanges (#120208)

codeRanges() had only comment/string/template states: a `/` in code was
checked for `//` and `/*` and otherwise treated as plain code, so the body
of a regex literal was lexed as code. A backtick inside a pattern (the
entities htmlReplacer character class, or a simple /`/) then pushed
'template' onto the stack and flipped template parity — every import
after it was classified as string text and left un-rewritten, so the
plugin failed with "Failed to resolve module specifier 'react'".
Regression from #117610.

Adopts open PR #120302 (credit: that author): a `/` that cannot follow a
value opens a regex-literal region — isRegexStart() is the standard
division-vs-regex heuristic (keyword/charclass/paren awareness, property
division stays code), regexEnd() honors escapes and [...] classes and
refuses to cross a newline, so a division that never closes on its line
stays plain code and its imports still rewrite. The pattern itself is
excluded from code ranges: import-looking text inside it is neither
matched nor rewritten in place.

Regression coverage: backtick-in-regex + import after it; the entities
htmlReplacer line; divisions stay code (imports after real division
still rewrite); `from 'react'` inside a pattern is never rewritten.
This commit is contained in:
Hermes Agent
2026-09-24 19:15:10 -05:00
committed by brooklyn!
parent 3d9adc0846
commit 188261f4bc
2 changed files with 189 additions and 1 deletions

View File

@@ -650,6 +650,85 @@ export default { id: 'quoted-spec', register: () => { globalThis.__captured = do
}
})
it('a backtick inside a regex literal does not flip code/string classification (#120208)', async () => {
// The regex backtick must not open a template: the react import after it
// stays code and is rewritten to the shim, not left bare.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"const backtick = /`/\nimport { useState } from 'react'\nconst label = `ok`\nexport default { id: 'repro', register() { void useState; void label; void backtick } }",
'repro'
)
expect(id).toBe('repro')
expect($pluginRecords.get()['repro']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('repro')
restore()
}
})
it('rewrites imports after the entities htmlReplacer regex (#120208)', async () => {
// entities 6.0.1 encode.js (via the hermes-toolsmith bundle): the
// character class contains a backtick, then SDK/react imports follow.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"var htmlReplacer = /[\\t\\n\\f!-,./:-@[-`{-}\\^@-\\uFFFF]/g;\nimport { host } from '@hermes/plugin-sdk'\nconst label = `ok`\nexport default { id: 'entities-re', register() { void host; void label; void htmlReplacer } }",
'entities-re'
)
expect(id).toBe('entities-re')
expect($pluginRecords.get()['entities-re']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('entities-re')
restore()
}
})
it('divisions stay code: imports after real division still rewrite (#120208)', async () => {
// The other direction: `/` between two values is a division, not a
// pattern, so the import below must still be seen and rewritten.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"const total = 10, count = 4, earned = 6\nconst half = total / count + earned / 2\nimport { host } from '@hermes/plugin-sdk'\nconst label = `n=${half}`\nexport default { id: 'division', register() { void host; void label } }",
'division'
)
expect(id).toBe('division')
expect($pluginRecords.get()['division']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('division')
restore()
}
})
it('does not read import syntax inside a regex literal (#120208)', async () => {
// A regex body is not code: `from 'react'` inside it must not be
// rewritten in place (which would corrupt the pattern).
const restore = withBlobReroute()
try {
;(globalThis as unknown as { __capturedRe?: unknown }).__capturedRe = undefined
const id = await loadRuntimePlugin(
"const re = /from 'react'/\nexport default { id: 'regex-spec', register: () => { globalThis.__capturedRe = re.source } }",
'regex-spec'
)
expect(id).toBe('regex-spec')
expect((globalThis as unknown as { __capturedRe?: string }).__capturedRe).toBe("from 'react'")
} finally {
unloadRuntimePlugin('regex-spec')
delete (globalThis as unknown as { __capturedRe?: unknown }).__capturedRe
restore()
}
})
it('still rewrites a real mapped import', async () => {
// The fix must not swing the other way: the SDK import is the load path.
const restore = withBlobReroute()

View File

@@ -75,7 +75,101 @@ const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+
* specifier regex is not syntax-aware, so this is what keeps a plugin's own
* copy and comments — `const label = 'Copy keys from'`, `// import 'x'` —
* from being read as import syntax (rejected as "unsupported import") or
* rewritten in place (a mapped specifier inside a string must stay verbatim). */
* rewritten in place (a mapped specifier inside a string must stay verbatim).
* Regex literals are excluded too: a quote or backtick inside a pattern
* (#120208) must not open a string/template state. */
/** Keywords after which a `/` opens a regex literal, never a division. */
const regexKeywordRe = /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/
/** True when the `/` at `slash` (already known not to start `//` or `/*`)
* opens a regex literal: the previous significant char cannot end a value.
* Standard division-vs-regex heuristic. */
function isRegexStart(source: string, slash: number): boolean {
let j = slash - 1
while (j >= 0 && /\s/.test(source[j])) {
j -= 1
}
if (j < 0) {
return true
}
const prev = source[j]
// Postfix `++`/`--` ends a value (division); a lone `+`/`-` cannot.
if (prev === '+' || prev === '-') {
return source[j - 1] !== prev
}
// Identifier, number, string/template end, `)` or `]` end a value.
if (prev === ')' || prev === ']' || prev === "'" || prev === '"' || prev === '`') {
return false
}
// Block-end `}` resolves toward regex — `} /re/` (statement-start
// pattern) is real code, `} / 2` (dividing a block) is not. Revisit if a
// plugin ever divides a block result.
if (prev === '}') {
return true
}
if (/[A-Za-z0-9_$]/.test(prev)) {
let k = j
while (k >= 0 && /[A-Za-z0-9_$]/.test(source[k])) {
k -= 1
}
// `x.return / 2` divides a property, it is not `return /re/`.
if (source[k] === '.') {
return false
}
return regexKeywordRe.test(source.slice(k + 1, j + 1))
}
return true
}
/** End offset (exclusive) of the regex literal opened at `slash`, or -1 when
* the pattern never closes on this line (so the `/` was a division).
* Escapes and `[...]` classes are honored so a quote or backtick inside the
* pattern (#120208) cannot leak into the surrounding lex. */
function regexEnd(source: string, slash: number): number {
let j = slash + 1
let inClass = false
while (j < source.length) {
const c = source[j]
if (c === '\\') {
j += 2
} else if (c === '\n') {
return -1
} else if (c === '[') {
inClass = true
j += 1
} else if (c === ']') {
inClass = false
j += 1
} else if (c === '/' && !inClass) {
j += 1
while (j < source.length && /[A-Za-z]/.test(source[j])) {
j += 1
}
return j
} else {
j += 1
}
}
return -1
}
function codeRanges(source: string): Array<[number, number]> {
const ranges: Array<[number, number]> = []
const stack: Array<'expr' | 'template'> = []
@@ -115,6 +209,21 @@ function codeRanges(source: string): Array<[number, number]> {
stack.push('template')
state = 'template'
i += 1
} else if (ch === '/') {
// A lone `/` (not `//` or `/*`, handled above) opens a regex literal
// when the previous significant token cannot end a value (#120208).
// Otherwise it is a division and stays plain code.
const end = isRegexStart(source, i) ? regexEnd(source, i) : -1
if (end > 0) {
// The pattern is not code: import-looking text inside it must
// neither match nor be rewritten in place.
closeCode(i)
i = end
codeStart = i
} else {
i += 1
}
} else if (ch === '}' && stack[stack.length - 1] === 'expr') {
closeCode(i)
stack.pop()