mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(runner): mask regex literals in the real-process wait guard
Inside a template's `${...}` expression, the interpolation scanner
counted every `{` and `}` toward the depth that finds the
interpolation's own closing brace. A regex literal like `/}/` or
`/^a{2,4}$/` carries a brace that belongs to the pattern, not to the
surrounding code, so the scanner closed the interpolation early and
hid a real wait call written after the regex.
Mask each regex literal to spaces before this depth count sees any of
its characters, the same way a string or template literal is already
masked. A short heuristic based on the character before the opening
`/` tells a regex literal from a division operator. Add tests for a
regex with a closing brace, a regex with a quantifier's paired braces,
and a wait-call pattern written only inside a regex literal's own
text.
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2 files changed
+113
-8
No files matched your search
@@ -170,6 +170,40 @@ test("rejects a vi.waitFor( written inside a template literal interpolation", ()
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects a vi.waitFor( that follows a regex literal with a closing brace inside a template literal interpolation", () => {
|
||||
const source = [
|
||||
"async function example() {",
|
||||
" const message = `status: ${/}/.test(x) ? await vi.waitFor(() => expect(x).toBe(1)) : false}`;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(findBareRealProcessWaits(source), [
|
||||
{ line: 2, pattern: "vi.waitFor(" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects a vi.waitFor( that follows a regex literal with a quantifier's opening and closing braces inside a template literal interpolation", () => {
|
||||
const source = [
|
||||
"async function example() {",
|
||||
" const message = `status: ${/^a{2,4}$/.test(x) ? await vi.waitFor(() => expect(x).toBe(1)) : false}`;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(findBareRealProcessWaits(source), [
|
||||
{ line: 2, pattern: "vi.waitFor(" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores a vi.waitFor( written inside a regex literal's own text, inside a template literal interpolation", () => {
|
||||
const source = [
|
||||
"async function example() {",
|
||||
" const message = `status: ${/vi\\.waitFor\\(/.test(callSite) ? \"bad\" : \"ok\"}`;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(findBareRealProcessWaits(source), []);
|
||||
});
|
||||
|
||||
test("still flags a real bare vi.waitFor( on the line after a comment mentioning the pattern", () => {
|
||||
const source = [
|
||||
"async function example() {",
|
||||
|
||||
@@ -48,15 +48,82 @@ async function collectSourceFiles(target) {
|
||||
return files;
|
||||
}
|
||||
|
||||
// Report whether the `/` at `source[index]` most likely opens a regex
|
||||
// literal rather than divides two values. This looks at the last non-space
|
||||
// character before `index`: an identifier, a number, or a `)`/`]` that
|
||||
// closes a value means `/` divides, unless that identifier is a keyword
|
||||
// (`return`, `typeof`, `case`, and so on) that itself expects an expression
|
||||
// next. Anything else — an operator, an opening bracket, the start of the
|
||||
// scanned text, or none of the above — means `/` opens a regex literal.
|
||||
// This is the same ambiguity a full JavaScript parser resolves with grammar
|
||||
// state; this heuristic only needs to hold for the code this guard scans.
|
||||
const REGEX_PRECEDING_KEYWORDS = new Set([
|
||||
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
||||
"yield", "case", "throw", "do", "else",
|
||||
]);
|
||||
|
||||
function isRegexLiteralStart(source, index) {
|
||||
let previousIndex = index - 1;
|
||||
while (previousIndex >= 0 && /\s/.test(source[previousIndex])) {
|
||||
previousIndex -= 1;
|
||||
}
|
||||
if (previousIndex < 0) return true;
|
||||
const previousCharacter = source[previousIndex];
|
||||
if (!/[\w$)\]]/.test(previousCharacter)) return true;
|
||||
const wordMatch = source.slice(0, previousIndex + 1).match(/[A-Za-z_$][\w$]*$/);
|
||||
const word = wordMatch?.[0] ?? "";
|
||||
return REGEX_PRECEDING_KEYWORDS.has(word);
|
||||
}
|
||||
|
||||
// Mask a `/pattern/flags` regex literal, starting at the opening `/` in
|
||||
// `source[start]`, with spaces up to and including its trailing flags. A
|
||||
// `{`, `}`, `(`, `)`, `[`, or `]` inside the pattern is part of the regex,
|
||||
// not executable-code structure, so masking the whole literal — the same
|
||||
// way a string literal is masked — keeps it from changing brace depth or
|
||||
// confusing paren matching. A `/` inside a `[...]` character class does not
|
||||
// close the literal.
|
||||
function maskRegexLiteral(source, masked, start, end) {
|
||||
let index = start;
|
||||
masked[index] = " ";
|
||||
index += 1;
|
||||
let inCharacterClass = false;
|
||||
while (index < end) {
|
||||
const character = source[index];
|
||||
if (character === "\n") return index;
|
||||
if (character === "\\" && index + 1 < end) {
|
||||
masked[index] = " ";
|
||||
index += 1;
|
||||
if (source[index] !== "\n") masked[index] = " ";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "[") inCharacterClass = true;
|
||||
if (character === "]") inCharacterClass = false;
|
||||
masked[index] = " ";
|
||||
index += 1;
|
||||
if (character === "/" && !inCharacterClass) {
|
||||
while (index < end && /[a-zA-Z]/.test(source[index])) {
|
||||
masked[index] = " ";
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// Scan `source` from `start` up to `end` as executable code, writing spaces
|
||||
// into `masked` for each comment and each string- or template-literal quote
|
||||
// character it finds, then handing the literal body to `maskLiteralBody`.
|
||||
// Every other character stays as it is in `source`, so a wait-call pattern
|
||||
// written as real code — including inside a template's `${...}`
|
||||
// expression — still matches once masking is done. When `stopAtOwnCloseBrace`
|
||||
// is true, `start` points just past a `${` this function did not consume,
|
||||
// and the function tracks nested `{`/`}` pairs so it returns right after the
|
||||
// `}` that closes this same interpolation, leaving both braces unmasked.
|
||||
// into `masked` for each comment, each regex literal, and each string- or
|
||||
// template-literal quote character it finds, then handing the literal body
|
||||
// to `maskLiteralBody`. Every other character stays as it is in `source`, so
|
||||
// a wait-call pattern written as real code — including inside a template's
|
||||
// `${...}` expression — still matches once masking is done. When
|
||||
// `stopAtOwnCloseBrace` is true, `start` points just past a `${` this
|
||||
// function did not consume, and the function tracks nested `{`/`}` pairs so
|
||||
// it returns right after the `}` that closes this same interpolation,
|
||||
// leaving both braces unmasked. Masking a regex literal whole, before this
|
||||
// tracking sees any of its characters, keeps a brace inside the regex from
|
||||
// changing that count.
|
||||
function maskCode(source, masked, start, end, stopAtOwnCloseBrace) {
|
||||
let index = start;
|
||||
let braceDepth = stopAtOwnCloseBrace ? 1 : 0;
|
||||
@@ -85,6 +152,10 @@ function maskCode(source, masked, start, end, stopAtOwnCloseBrace) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "/" && isRegexLiteralStart(source, index)) {
|
||||
index = maskRegexLiteral(source, masked, index, end);
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
masked[index] = " ";
|
||||
index = maskLiteralBody(source, masked, index + 1, end, character, false);
|
||||
|
||||
Reference in new issue
Block a user