mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 05:01:48 -10:00
* 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]>
298 lines
10 KiB
JavaScript
Executable File
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`);
|