From fdc16817d703ecb9897ac26b1bb5b7fb8c3ef973 Mon Sep 17 00:00:00 2001 From: Priya Raman Date: Fri, 4 Sep 2026 02:00:34 +0000 Subject: [PATCH] fix(runner): require a standalone await token in the wait guard The regex-literal-start check treated an await-named property, such as obj.await, as the await keyword. Code that divides after such a property, like ${obj.await / divisor + (await vi.waitFor(...))}, could then mask the real vi.waitFor( call as part of a false regex literal and hide it from the guard. The check now looks at the character before the matched keyword. A dot there means the word is a property name, not the keyword, so the slash after it divides. Co-authored-by: Paperclip --- .../check-bare-real-process-waits.test.mjs | 12 ++++++++++++ .../scripts/lib/bare-real-process-wait.mjs | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/paperclip-runner/scripts/check-bare-real-process-waits.test.mjs b/packages/paperclip-runner/scripts/check-bare-real-process-waits.test.mjs index 24eaf6f0c6a..907a68a6381 100644 --- a/packages/paperclip-runner/scripts/check-bare-real-process-waits.test.mjs +++ b/packages/paperclip-runner/scripts/check-bare-real-process-waits.test.mjs @@ -216,6 +216,18 @@ test("ignores a vi.waitFor( written inside a regex literal's own text, inside a assert.deepEqual(findBareRealProcessWaits(source), []); }); +test("rejects a vi.waitFor( that follows division after an await-named property, inside a template literal interpolation", () => { + const source = [ + "async function example() {", + " const message = `status: ${obj.await / divisor + (await vi.waitFor(() => expect(x).toBe(1)))}`;", + "}", + ].join("\n"); + + assert.deepEqual(findBareRealProcessWaits(source), [ + { line: 2, pattern: "vi.waitFor(" }, + ]); +}); + test("still flags a real bare vi.waitFor( on the line after a comment mentioning the pattern", () => { const source = [ "async function example() {", diff --git a/packages/paperclip-runner/scripts/lib/bare-real-process-wait.mjs b/packages/paperclip-runner/scripts/lib/bare-real-process-wait.mjs index f76d8e15346..dcc290e888e 100644 --- a/packages/paperclip-runner/scripts/lib/bare-real-process-wait.mjs +++ b/packages/paperclip-runner/scripts/lib/bare-real-process-wait.mjs @@ -62,6 +62,20 @@ const REGEX_PRECEDING_KEYWORDS = new Set([ "yield", "case", "throw", "do", "else", "await", ]); +// A word that reads as one of `REGEX_PRECEDING_KEYWORDS` is a property +// name, not the keyword, when a `.` (a plain member access or the last +// character of an optional-chain `?.`) sits directly before it, with only +// space allowed in between. `obj.await` is a value, so the `/` right after +// it divides; only a standalone `await` puts the `/` in an expression +// position. +function isKeywordShapedPropertyName(source, wordStartIndex) { + let beforeWordIndex = wordStartIndex - 1; + while (beforeWordIndex >= 0 && /\s/.test(source[beforeWordIndex])) { + beforeWordIndex -= 1; + } + return beforeWordIndex >= 0 && source[beforeWordIndex] === "."; +} + function isRegexLiteralStart(source, index) { let previousIndex = index - 1; while (previousIndex >= 0 && /\s/.test(source[previousIndex])) { @@ -72,7 +86,10 @@ function isRegexLiteralStart(source, index) { 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); + if (!REGEX_PRECEDING_KEYWORDS.has(word)) return false; + const wordStartIndex = previousIndex + 1 - word.length; + if (isKeywordShapedPropertyName(source, wordStartIndex)) return false; + return true; } // Mask a `/pattern/flags` regex literal, starting at the opening `/` in