diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 8d5a933d..5f9d0654 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -70,9 +70,168 @@ jobs: const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs")); // A test can live in a tests/ dir, a *_test.rs file, or (the common Rust // pattern) an inline #[test] / #[cfg(test)] block added inside the source file. - const addsInlineTest = files.some(f => - f.filename.endsWith(".rs") && f.patch && - /^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch)); + // Async tests use a namespaced attribute (#[tokio::test], #[async_std::test], + // possibly with args like #[tokio::test(flavor = ...)]) rather than the bare + // #[test]. Anchor to a leading #[ (horizontal whitespace only, so the attribute + // itself must be on the added line) so ::test in ordinary code (indexing, + // comments, strings) doesn't count, require cfg(test) to close before accepting + // it as a test gate, and allow the whitespace Rust permits around :: and cfg(. + const testAttrRe = + /#[ \s]*\[[ \s]*(?:cfg[ \s]*\([ \s]*test[ \s]*\)|(?:[\w-]+(?:[ \s]*::[ \s]*[\w-]+)*[ \s]*::[ \s]*)?test\b[ \s]*[\]\()])/g; + + // Strip comments and string contents from a file of Rust, carrying lexical + // state (open block comment / raw string / normal string) and tracking + // original line mapping. + function stripRustFile(fileContent) { + let stripped = ""; + const lineMap = []; + const state = { blockCommentDepth: 0, rawStringDelim: null, inNormalString: false }; + const lines = fileContent.split("\n"); + + for (let lineNo = 1; lineNo <= lines.length; lineNo++) { + const content = lines[lineNo - 1]; + let i = 0; + while (i < content.length) { + if (state.blockCommentDepth > 0) { + if (content.slice(i, i + 2) === "*/") { + state.blockCommentDepth--; + i += 2; + } else if (content.slice(i, i + 2) === "/*") { + state.blockCommentDepth++; + i += 2; + } else { + i += 1; + } + } else if (state.rawStringDelim) { + const len = state.rawStringDelim.length; + if (content.slice(i, i + len) === state.rawStringDelim) { + state.rawStringDelim = null; + i += len; + } else { + i += 1; + } + } else if (state.inNormalString) { + if (content[i] === "\\") { + i += 2; + } else if (content[i] === '"') { + state.inNormalString = false; + i += 1; + } else { + i += 1; + } + } else { + if (content.slice(i, i + 2) === "//") { + break; // skip rest of line + } + if (content.slice(i, i + 2) === "/*") { + state.blockCommentDepth = 1; + i += 2; + continue; + } + const charMatch = content.slice(i).match(/^'(?:[^'\\]|\\.)'/); + if (charMatch) { + i += charMatch[0].length; + continue; + } + const rawMatch = content.slice(i).match(/^r(#*)"/); + if (rawMatch) { + state.rawStringDelim = '"' + rawMatch[1]; + i += rawMatch[0].length; + continue; + } + if (content[i] === '"') { + state.inNormalString = true; + i += 1; + continue; + } + stripped += content[i]; + lineMap.push(lineNo); + i += 1; + } + } + if (lineNo < lines.length) { + stripped += "\n"; + lineMap.push(lineNo); + } + } + return { stripped, lineMap }; + } + + // New-file line numbers touched by "+" lines in a unified diff patch. + function addedLineNumbers(patch) { + const added = new Set(); + let newLine = null; + for (const line of patch.split("\n")) { + const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + newLine = parseInt(hunk[1], 10); + continue; + } + if (newLine === null) continue; + if (line.startsWith("+") && !line.startsWith("+++")) { + added.add(newLine); + newLine += 1; + } else if (!line.startsWith("-") || line.startsWith("---")) { + newLine += 1; // context line + } + } + return added; + } + + // A diff hunk carries no lexical context from lines outside it, so scanning + // patch text alone can misclassify an added line that sits inside a comment or + // raw string opened earlier in the file. Lex the complete head-version file + // instead, in file order, and only test the lines the diff actually added. + let addsInlineTest = false; + let contentFetchCount = 0; + for (const f of files) { + if (addsInlineTest) break; + if (!f.filename.endsWith(".rs") || !f.patch) continue; + + // Pre-filter: only fetch and parse file contents if the patch actually adds + // something that looks like a potential test attribute or cfg gate. + const patchHasPotentialTest = f.patch.split("\n").some(line => + line.startsWith("+") && !line.startsWith("+++") && + (line.toLowerCase().includes("test") || line.toLowerCase().includes("cfg")) + ); + if (!patchHasPotentialTest) continue; + + if (contentFetchCount >= 15) { + throw new Error("PR is too large: exceeded maximum number of file content fetches (15) for inline test analysis."); + } + contentFetchCount++; + + let fileContent; + try { + const res = await github.rest.repos.getContent({ + owner, repo, path: f.filename, ref: pr.head.sha, + }); + fileContent = Buffer.from(res.data.content, "base64").toString("utf8"); + } catch (e) { + if (e.status === 404) { + continue; // file since deleted; nothing to lex + } + throw new Error(`Failed to fetch content for ${f.filename}: ${e.message || e}`); + } + + const added = addedLineNumbers(f.patch); + if (added.size === 0) continue; + + const { stripped, lineMap } = stripRustFile(fileContent); + let match; + testAttrRe.lastIndex = 0; + while ((match = testAttrRe.exec(stripped)) !== null) { + const start = match.index; + const end = testAttrRe.lastIndex; + for (let k = start; k < end; k++) { + if (added.has(lineMap[k])) { + addsInlineTest = true; + break; + } + } + if (addsInlineTest) break; + } + } const touchedTests = names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest; if (changedRust && !touchedTests) want.add("needs-tests");