|
| 1 | +#!/usr/bin/env node |
| 2 | +// Case matrix for the inline-test detector embedded in |
| 3 | +// .github/workflows/pr-triage.yml. The workflow runs on pull_request_target |
| 4 | +// and deliberately never checks out PR code, so the detector cannot be tested |
| 5 | +// where it runs; this script extracts the fenced TRIAGE_DETECTOR block from |
| 6 | +// the committed workflow body and exercises it here, where pr-checks.yml DOES |
| 7 | +// check out the proposed workflow. If the fence markers move or the block |
| 8 | +// stops being self-contained, this script fails loudly rather than testing a |
| 9 | +// stale copy. |
| 10 | +// |
| 11 | +// The matrix encodes the review contract for the detector |
| 12 | +// (Gitlawb/node#277): legal Rust separators between the attribute path and |
| 13 | +// its ]/( delimiter must be accepted (line comments, nested block comments, |
| 14 | +// splits onto immediately following added lines), while unrelated patch |
| 15 | +// records — delimiter-looking lines before the path, later in the hunk, or in |
| 16 | +// another hunk — must never complete a path they do not adjoin. False |
| 17 | +// negatives here SUPPRESS the needs-tests label silently, so every uncertain |
| 18 | +// path in the detector is required to answer "no inline test". |
| 19 | + |
| 20 | +import { readFileSync } from "node:fs"; |
| 21 | +import { dirname, join } from "node:path"; |
| 22 | +import { fileURLToPath } from "node:url"; |
| 23 | + |
| 24 | +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 25 | +const workflow = readFileSync( |
| 26 | + join(repoRoot, ".github/workflows/pr-triage.yml"), |
| 27 | + "utf8" |
| 28 | +); |
| 29 | + |
| 30 | +const BEGIN = "// TRIAGE_DETECTOR_BEGIN"; |
| 31 | +const END = "// TRIAGE_DETECTOR_END"; |
| 32 | +const begin = workflow.indexOf(BEGIN); |
| 33 | +const end = workflow.indexOf(END); |
| 34 | +if (begin === -1 || end === -1 || end <= begin) { |
| 35 | + console.error("FAIL: TRIAGE_DETECTOR fence not found in pr-triage.yml"); |
| 36 | + process.exit(1); |
| 37 | +} |
| 38 | +const block = workflow.slice(begin + BEGIN.length, end); |
| 39 | + |
| 40 | +let patchAddsInlineTest; |
| 41 | +try { |
| 42 | + const factory = new Function(`${block}\nreturn patchAddsInlineTest;`); |
| 43 | + patchAddsInlineTest = factory(); |
| 44 | +} catch (err) { |
| 45 | + console.error( |
| 46 | + "FAIL: fenced detector block is not self-contained JavaScript:", |
| 47 | + err.message |
| 48 | + ); |
| 49 | + process.exit(1); |
| 50 | +} |
| 51 | + |
| 52 | +// Each patch is the `patch` field GitHub's listFiles API returns: hunk |
| 53 | +// headers plus +/-/space-prefixed lines, no ---/+++ file headers. |
| 54 | +const cases = [ |
| 55 | + // ── Accepted spellings ──────────────────────────────────────────────── |
| 56 | + ["bare same-line", "@@ -1,0 +1,2 @@\n+#[test]\n+fn a() {}", true], |
| 57 | + ["cfg(test)", "@@ -1,0 +1,1 @@\n+#[cfg(test)]", true], |
| 58 | + ["indented with inner space", "@@ -1,0 +1,1 @@\n+ #[ test ]", true], |
| 59 | + [ |
| 60 | + "namespaced with args", |
| 61 | + '@@ -1,0 +1,1 @@\n+#[tokio::test(flavor = "multi_thread")]', |
| 62 | + true, |
| 63 | + ], |
| 64 | + ["test_case harness", "@@ -1,0 +1,1 @@\n+#[test_case(1)]", true], |
| 65 | + ["wasm_bindgen_test harness", "@@ -1,0 +1,1 @@\n+#[wasm_bindgen_test]", true], |
| 66 | + ["raw identifier", "@@ -1,0 +1,1 @@\n+#[r#test]", true], |
| 67 | + [ |
| 68 | + "line comment then closer on next added line", |
| 69 | + "@@ -1,0 +1,2 @@\n+#[test // rationale\n+]", |
| 70 | + true, |
| 71 | + ], |
| 72 | + [ |
| 73 | + "nested block comment, same line", |
| 74 | + "@@ -1,0 +1,1 @@\n+#[test /* outer /* inner */ outer */]", |
| 75 | + true, |
| 76 | + ], |
| 77 | + [ |
| 78 | + "block comment spanning added lines", |
| 79 | + "@@ -1,0 +1,3 @@\n+#[test /* why\n+ still why */ ]\n+fn a() {}", |
| 80 | + true, |
| 81 | + ], |
| 82 | + [ |
| 83 | + "path-only line, ( on the immediately following added line", |
| 84 | + "@@ -1,0 +1,2 @@\n+#[test_case\n+(1)]", |
| 85 | + true, |
| 86 | + ], |
| 87 | + [ |
| 88 | + "whitespace-only continuation before closer", |
| 89 | + "@@ -1,0 +1,3 @@\n+#[test\n+\t\n+]", |
| 90 | + true, |
| 91 | + ], |
| 92 | + // ── Rejected spellings and adversarial shapes ───────────────────────── |
| 93 | + ["rstest stays excluded", "@@ -1,0 +1,1 @@\n+#[rstest]", false], |
| 94 | + ["substring #[testable]", "@@ -1,0 +1,1 @@\n+#[testable]", false], |
| 95 | + ["substring #[contest]", "@@ -1,0 +1,1 @@\n+#[contest]", false], |
| 96 | + [ |
| 97 | + "delimiter-looking line BEFORE the path", |
| 98 | + "@@ -1,0 +1,2 @@\n+(\n+#[test_case", |
| 99 | + false, |
| 100 | + ], |
| 101 | + [ |
| 102 | + "raw-string fixture path + unrelated ( later in the same hunk", |
| 103 | + '@@ -1,0 +1,5 @@\n+let s = r#"\n+#[test_case\n+not a separator token\n+"#;\n+let t = (1);', |
| 104 | + false, |
| 105 | + ], |
| 106 | + [ |
| 107 | + "path at end of one hunk, closer in another hunk", |
| 108 | + "@@ -1,0 +1,1 @@\n+#[test_case\n@@ -10,0 +11,1 @@\n+(1)]", |
| 109 | + false, |
| 110 | + ], |
| 111 | + [ |
| 112 | + "closer only on a context line", |
| 113 | + "@@ -1,1 +1,1 @@\n+#[test_case\n (1)]", |
| 114 | + false, |
| 115 | + ], |
| 116 | + [ |
| 117 | + "closer only on a removed line", |
| 118 | + "@@ -1,1 +1,1 @@\n+#[test_case\n-(1)]", |
| 119 | + false, |
| 120 | + ], |
| 121 | + [ |
| 122 | + "unfinished block comment never closes", |
| 123 | + "@@ -1,0 +1,2 @@\n+#[test /*\n+ still open", |
| 124 | + false, |
| 125 | + ], |
| 126 | + [ |
| 127 | + "continuation bound exceeded stays loud", |
| 128 | + "@@ -1,0 +1,40 @@\n+#[test /*\n" + "+ filler\n".repeat(30) + "+ */ ]", |
| 129 | + false, |
| 130 | + ], |
| 131 | +]; |
| 132 | + |
| 133 | +let failures = 0; |
| 134 | +for (const [name, patch, expected] of cases) { |
| 135 | + const got = patchAddsInlineTest(patch); |
| 136 | + if (got !== expected) { |
| 137 | + failures += 1; |
| 138 | + console.error(`FAIL: ${name}: expected ${expected}, got ${got}`); |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +// Runtime probe: the detector walks fork-controlled input on |
| 143 | +// pull_request_target, so a pathological head must not stall the job. The |
| 144 | +// long `_a` run is the historical exponential-backtracking shape for the |
| 145 | +// attribute-path regex; the comment run exercises the scanner loop. |
| 146 | +const probes = [ |
| 147 | + ["long _a run", "@@ -1,0 +1,1 @@\n+#[test" + "_a".repeat(30000), false], |
| 148 | + [ |
| 149 | + "long unclosed comment line", |
| 150 | + "@@ -1,0 +1,1 @@\n+#[test /*" + " *".repeat(30000), |
| 151 | + false, |
| 152 | + ], |
| 153 | +]; |
| 154 | +for (const [name, patch, expected] of probes) { |
| 155 | + const t0 = process.hrtime.bigint(); |
| 156 | + const got = patchAddsInlineTest(patch); |
| 157 | + const ms = Number(process.hrtime.bigint() - t0) / 1e6; |
| 158 | + if (got !== expected) { |
| 159 | + failures += 1; |
| 160 | + console.error(`FAIL: probe ${name}: expected ${expected}, got ${got}`); |
| 161 | + } |
| 162 | + if (ms > 1000) { |
| 163 | + failures += 1; |
| 164 | + console.error(`FAIL: probe ${name}: took ${ms.toFixed(0)}ms (>1000ms)`); |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +if (failures) { |
| 169 | + console.error(`${failures} failure(s) across ${cases.length + probes.length} cases`); |
| 170 | + process.exit(1); |
| 171 | +} |
| 172 | +console.log(`ok: ${cases.length + probes.length} detector cases passed`); |
0 commit comments