Files
node/scripts/test-pr-triage-detect.mjs
beardthelionandKevin Codex cd27756134 ci(triage): match test as a name segment, not just a whole component (#277)
* ci(triage): match test as a name segment, not just a whole component

#202 landed the namespaced-attribute fix, so main already detects
#[tokio::test] and #[sqlx::test]. What it still misses is a third-party
harness whose final path component carries `test` as an underscore
segment. #[test_case(1)] and #[wasm_bindgen_test] both fall through and
wrongly earn the PR a needs-tests label.

Widen the final component to (?:[A-Za-z0-9]+_)*test(?:_[A-Za-z0-9]+)*,
keeping the leading ::, the r# form, and the whitespace tolerance #202
added. Differential over 972 real file-patches from 400 commits: no
verdict changes either way, so this is a forward-looking net for
harnesses the tree does not use yet, not a fix for a current mislabel.

Segment rather than substring, on purpose. addsInlineTest suppresses the
label when it matches, so a false positive is the quiet failure:
#[contest] or #[latest] would clear needs-tests on a PR that added no
tests and nobody would notice. The cost is the harness names with no
underscore, such as #[rstest], which stay unmatched and produce the loud,
author-correctable failure instead.

The segment classes are [A-Za-z0-9]+ rather than \w+ so that `_` is only
ever the literal delimiter. \w+ contains `_`, which makes the repetition
ambiguous and backtracks exponentially. That matters here specifically
because the workflow triggers on pull_request_target, so f.patch is
fork-controlled text on a privileged runner: with \w+, `#[test` followed
by a long `_a` run took 71ms at 22 characters and roughly quadrupled
every two more, which stalls the triage job for any contributor who can
open a PR. The character-class form measures linear across every
adversarial input tried.

* fix(ci): allow Rust token separators before test attribute delimiters

The needs-tests inline detector required ] or ( immediately after horizontal
whitespace, so valid spellings like #[test /* rationale */] or a path split
across added patch lines still triggered needs-tests. Match block comments,
line breaks, and split-line closers instead.

* ci(triage): scan attribute closers token-aware, bound to adjoined added lines

The two-regex fallback matched a path-only attribute line and a ]/( line
anywhere else in the patch, so unrelated records could complete each other
and silently clear needs-tests; the mandatory separator regex also dropped
legal //-to-newline and nested block comments, relabeling real tests.
Replace both with one forward token scan that starts on the attribute's
added line, continues only through immediately adjoined added lines under
a hard bound, and answers 'no inline test' on anything uncertain.

Fence the detector for extraction and add scripts/test-pr-triage-detect.mjs,
run by a new pr-checks job, since pull_request_target executes the target
branch's workflow and can never exercise the proposed body.

* fix(ci): skip test attributes inside raw strings, block comments, and strings

patchAddsInlineTest had no Rust lexical state, so a #[test_case] inside a
raw string (r#"..."#), block comment, or regular string literal, followed
by ] or ( on an adjoining added line, falsely cleared needs-tests. That is
the silent failure direction the detector is supposed to fail loud against,
and it is newly reachable because the underscore-segment regex introduced
by this branch matches forms the old test\b did not.

A pre-pass now walks every patch line (context and added, not removals)
tracking raw strings (r#*", br#*", cr#*"), block comments (/* */ with
nesting), regular strings (which can span newlines in Rust), and line
comments. Added lines that start inside any of these are skipped as
candidates. The pre-pass resets at hunk headers, is O(n) in patch length,
and only sees what the patch shows: a non-code region opened before the
first context line of a hunk is invisible, and the candidate is still
scanned (conservative, fails toward the loud label).

Also corrects the failure-direction comment in test-pr-triage-detect.mjs:
false positives suppress needs-tests (they set touchedTests, which clears
the label), while false negatives apply it (the loud, corrigible
direction). The previous text had them backwards.

Adds 7 regression cases: raw-string, block-comment, no-hash raw-string,
and multiline regular-string false positives (reject), plus raw-string,
block-comment, and regular-string closing before a real test attribute on
the next added line (accept). Revert-check confirmed the pre-pass is
load-bearing for both the nonCodeStart skip and the inString carry:
disabling either makes the corresponding reject cases go RED, restoring
makes all 31 pass.

* fix(ci): recognize char literals so their contents cannot open string state

The lexical pre-pass in patchAddsInlineTest missed real tests when an
earlier added line contained a double-quote char literal like '"' or
b'"'. The scanner saw the " inside the char token and set inString,
which persisted to the next line and suppressed a following #[test].
Add bounded char-literal recognition: on seeing ', look ahead 2-4 chars
(or to } for \u escapes) for a closing '. If found, skip the whole
literal. If not, it is a lifetime or label, and the apostrophe is
skipped as a regular char. Not a skip-to-next-' rule; lifetimes and
labels fall through unchanged.

* fix(ci): bound the \u{ escape scan to prevent quadratic stall

The char-literal pre-pass scanned to the next } with no bound when it
saw \u{, so a fork-controlled patch repeating '\u{ thousands of times
made the triage job quadratic (21s for 30000 reps). Bound the scan to
20 chars past the { (a valid \u{...} holds at most 6 hex digits). Add a
runtime probe for the malformed-escape shape.

* chore: refresh PR head

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-05 16:33:22 +00:00

298 lines
10 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Case matrix for the inline-test detector embedded in
// .github/workflows/pr-triage.yml. The workflow runs on pull_request_target
// and deliberately never checks out PR code, so the detector cannot be tested
// where it runs; this script extracts the fenced TRIAGE_DETECTOR block from
// the committed workflow body and exercises it here, where pr-checks.yml DOES
// check out the proposed workflow. If the fence markers move or the block
// stops being self-contained, this script fails loudly rather than testing a
// stale copy.
//
// The matrix encodes the review contract for the detector
// (Gitlawb/node#277): legal Rust separators between the attribute path and
// its ]/( delimiter must be accepted (line comments, nested block comments,
// splits onto immediately following added lines), while unrelated patch
// records - delimiter-looking lines before the path, later in the hunk, or in
// another hunk - must never complete a path they do not adjoin. False
// positives here SUPPRESS the needs-tests label silently (they set
// touchedTests, which clears the label), while false negatives apply it
// (the loud, corrigible direction). So every uncertain path in the detector
// is required to answer "no inline test".
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const workflow = readFileSync(
join(repoRoot, ".github/workflows/pr-triage.yml"),
"utf8"
);
const BEGIN = "// TRIAGE_DETECTOR_BEGIN";
const END = "// TRIAGE_DETECTOR_END";
const begin = workflow.indexOf(BEGIN);
const end = workflow.indexOf(END);
if (begin === -1 || end === -1 || end <= begin) {
console.error("FAIL: TRIAGE_DETECTOR fence not found in pr-triage.yml");
process.exit(1);
}
const block = workflow.slice(begin + BEGIN.length, end);
let patchAddsInlineTest;
try {
const factory = new Function(`${block}\nreturn patchAddsInlineTest;`);
patchAddsInlineTest = factory();
} catch (err) {
console.error(
"FAIL: fenced detector block is not self-contained JavaScript:",
err.message
);
process.exit(1);
}
// Each patch is the `patch` field GitHub's listFiles API returns: hunk
// headers plus +/-/space-prefixed lines, no ---/+++ file headers.
const cases = [
// ── Accepted spellings ────────────────────────────────────────────────
["bare same-line", "@@ -1,0 +1,2 @@\n+#[test]\n+fn a() {}", true],
["cfg(test)", "@@ -1,0 +1,1 @@\n+#[cfg(test)]", true],
["indented with inner space", "@@ -1,0 +1,1 @@\n+ #[ test ]", true],
[
"namespaced with args",
'@@ -1,0 +1,1 @@\n+#[tokio::test(flavor = "multi_thread")]',
true,
],
["test_case harness", "@@ -1,0 +1,1 @@\n+#[test_case(1)]", true],
["wasm_bindgen_test harness", "@@ -1,0 +1,1 @@\n+#[wasm_bindgen_test]", true],
["raw identifier", "@@ -1,0 +1,1 @@\n+#[r#test]", true],
[
"line comment then closer on next added line",
"@@ -1,0 +1,2 @@\n+#[test // rationale\n+]",
true,
],
[
"nested block comment, same line",
"@@ -1,0 +1,1 @@\n+#[test /* outer /* inner */ outer */]",
true,
],
[
"block comment spanning added lines",
"@@ -1,0 +1,3 @@\n+#[test /* why\n+ still why */ ]\n+fn a() {}",
true,
],
[
"path-only line, ( on the immediately following added line",
"@@ -1,0 +1,2 @@\n+#[test_case\n+(1)]",
true,
],
[
"whitespace-only continuation before closer",
"@@ -1,0 +1,3 @@\n+#[test\n+\t\n+]",
true,
],
// ── Rejected spellings and adversarial shapes ─────────────────────────
["rstest stays excluded", "@@ -1,0 +1,1 @@\n+#[rstest]", false],
["substring #[testable]", "@@ -1,0 +1,1 @@\n+#[testable]", false],
["substring #[contest]", "@@ -1,0 +1,1 @@\n+#[contest]", false],
[
"delimiter-looking line BEFORE the path",
"@@ -1,0 +1,2 @@\n+(\n+#[test_case",
false,
],
[
"raw-string fixture path + unrelated ( later in the same hunk",
'@@ -1,0 +1,5 @@\n+let s = r#"\n+#[test_case\n+not a separator token\n+"#;\n+let t = (1);',
false,
],
[
"path at end of one hunk, closer in another hunk",
"@@ -1,0 +1,1 @@\n+#[test_case\n@@ -10,0 +11,1 @@\n+(1)]",
false,
],
[
"closer only on a context line",
"@@ -1,1 +1,1 @@\n+#[test_case\n (1)]",
false,
],
[
"closer only on a removed line",
"@@ -1,1 +1,1 @@\n+#[test_case\n-(1)]",
false,
],
[
"unfinished block comment never closes",
"@@ -1,0 +1,2 @@\n+#[test /*\n+ still open",
false,
],
[
"continuation bound exceeded stays loud",
"@@ -1,0 +1,40 @@\n+#[test /*\n" + "+ filler\n".repeat(30) + "+ */ ]",
false,
],
[
"raw-string fixture with adjoining closer",
'@@ -1,0 +1,4 @@\n+const FIXTURE: &str = r#"\n+#[test_case\n+(1)]\n+"#;',
false,
],
[
"block comment with adjoining closer",
'@@ -1,0 +1,4 @@\n+/* this is a comment\n+#[test_case\n+(1)]\n+end of comment */',
false,
],
[
"raw string with no hash delimiters",
'@@ -1,0 +1,4 @@\n+let s = r"\n+#[test_case\n+(1)]\n+";',
false,
],
[
"raw string closes then real test attribute on next line",
'@@ -1,0 +1,2 @@\n+let s = r#""#;\n+#[test_case(1)]',
true,
],
[
"block comment closes then real test attribute on next line",
'@@ -1,0 +1,2 @@\n+/* c */\n+#[test]',
true,
],
[
"multiline regular string with test attribute inside",
'@@ -1,0 +1,3 @@\n+const S: &str = "\n+#[test]\n+";',
false,
],
[
"regular string closes then real test on next line",
'@@ -1,0 +1,2 @@\n+let s = "text";\n+#[test]',
true,
],
[
"char literal with double quote then real test",
"@@ -1,0 +1,4 @@\n+const QUOTE: char = '\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE as u32, 34);\n+}",
true,
],
[
"byte char literal with double quote then real test",
"@@ -1,0 +1,4 @@\n+const QUOTE: u8 = b'\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE, 34);\n+}",
true,
],
[
"char literal with escaped quote then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\'';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 39);\n+}",
true,
],
[
"char literal with escaped backslash then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\\\';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 92);\n+}",
true,
],
[
"char literal with unicode escape then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\u{2764}';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 10084);\n+}",
true,
],
[
"lifetime in type then real test",
"@@ -1,0 +1,3 @@\n+fn foo<'a>(x: &'a str) {}\n+#[test]\n+fn real_test() {}",
true,
],
[
"label then real test",
"@@ -1,0 +1,3 @@\n+'label: loop {}\n+#[test]\n+fn real_test() {}",
true,
],
[
"static lifetime then real test",
"@@ -1,0 +1,3 @@\n+const S: &'static str = \"hi\";\n+#[test]\n+fn real_test() {}",
true,
],
[
"context line with stray closing quote then real test",
'@@ -1,1 +1,2 @@\n );"\n+#[test]',
true,
],
[
"context line with stray opening quote then real test",
'@@ -1,1 +1,2 @@\n let s = "\n+#[test]',
true,
],
[
"r# inside string on context line then real test",
'@@ -1,1 +1,2 @@\n let s = "r#";\n+#[test]',
true,
],
[
"/* inside string on context line then real test",
'@@ -1,1 +1,2 @@\n let s = "/*";\n+#[test]',
true,
],
// Known limitation: a non-code region opened by a context line is invisible
// to the pre-pass (context lines are not scanned). An added #[test] inside
// an existing raw string or block comment is scanned as code and may match.
// This is a false positive (silent direction), accepted because the old
// detector had the same behavior, the pattern is rare, and needs-tests is
// advisory. Scanning context lines to close this gap would re-introduce a
// false negative on the far more common case of a context line with a stray
// " from a string that opened before the visible window.
[
"known limitation: #[test] inside context-opened raw string (FP)",
'@@ -1,1 +1,2 @@\n const FIX: &str = r#"\n+#[test]\n "#;',
true,
],
[
"known limitation: #[test_case] inside context-opened raw string (FP)",
'@@ -1,1 +1,3 @@\n const FIX: &str = r#"\n+#[test_case\n+(1)]\n "#;',
true,
],
[
"known limitation: #[test] inside context-opened block comment (FP)",
'@@ -1,1 +1,2 @@\n /*\n+#[test]\n */',
true,
],
];
let failures = 0;
for (const [name, patch, expected] of cases) {
const got = patchAddsInlineTest(patch);
if (got !== expected) {
failures += 1;
console.error(`FAIL: ${name}: expected ${expected}, got ${got}`);
}
}
// Runtime probe: the detector walks fork-controlled input on
// pull_request_target, so a pathological head must not stall the job. The
// long `_a` run is the historical exponential-backtracking shape for the
// attribute-path regex; the comment run exercises the scanner loop.
const probes = [
["long _a run", "@@ -1,0 +1,1 @@\n+#[test" + "_a".repeat(30000), false],
[
"long unclosed comment line",
"@@ -1,0 +1,1 @@\n+#[test /*" + " *".repeat(30000),
false,
],
[
"long malformed \\u{ escape run (quadratic guard)",
"@@ -1,0 +1,1 @@\n+" + "'\\u{".repeat(30000),
false,
],
];
for (const [name, patch, expected] of probes) {
const t0 = process.hrtime.bigint();
const got = patchAddsInlineTest(patch);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
if (got !== expected) {
failures += 1;
console.error(`FAIL: probe ${name}: expected ${expected}, got ${got}`);
}
if (ms > 1000) {
failures += 1;
console.error(`FAIL: probe ${name}: took ${ms.toFixed(0)}ms (>1000ms)`);
}
}
if (failures) {
console.error(`${failures} failure(s) across ${cases.length + probes.length} cases`);
process.exit(1);
}
console.log(`ok: ${cases.length + probes.length} detector cases passed`);