jules-orchestrator-kit 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/security.mjs CHANGED
@@ -1199,7 +1199,11 @@ const SPECIFIC_ASSERTION = new RegExp(
1199
1199
  // sub-test callback named its argument — `ct` as often as `t`. Bounded to
1200
1200
  // a short receiver so `results.match(...)` on an ordinary object is not
1201
1201
  // mistaken for an assertion; a heuristic, and stated as one.
1202
- "\\b[a-z_$][a-z0-9_$]{0,2}\\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects)\\s*\\(",
1202
+ // `is`/`not` are AVA's value assertions (`t.is(actual, expected)`),
1203
+ // measurable on P-Limit's root `test.js`: without them the guard watched a
1204
+ // suite whose every check was `t.is(...)` and counted no assertions at
1205
+ // all.
1206
+ "\\b[a-z_$][a-z0-9_$]{0,2}\\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects|is|not|like)\\s*\\(",
1203
1207
  ].join("|"),
1204
1208
  "i"
1205
1209
  );
@@ -1240,12 +1244,85 @@ const blankLiterals = (str) =>
1240
1244
  /(?<![\w$])(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|-?\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?\d+)?)/g,
1241
1245
  "\u0000N"
1242
1246
  )
1247
+ // A JS conditional expectation `cond ? a : b` whose branches carry
1248
+ // literals is an expected value, whatever it evaluates to. Without this,
1249
+ // `expect(x).toBe(3)` becoming `expect(x).toBe(x === 2 ? 3 : -1)` — the
1250
+ // JavaScript spelling of F03's Python ternary — landed in a different
1251
+ // shape bucket and never paired. Only a conditional holding a collapsed
1252
+ // literal collapses (so the `?` of an optional chain or a ternary over
1253
+ // bare variables is left untouched), and Python's `x if c else y` cannot
1254
+ // match this JS punctuation.
1255
+ .replace(/\?[^?\n;:]*[\u0000][SN][^?\n;:]*:[^?\n;:]*[\u0000][SN][^?\n;:]*/g, "\u0000C")
1243
1256
  .replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "\u0000B")
1244
1257
  // Whitespace is dropped, not collapsed: the shape is compared for
1245
1258
  // equality only, and a reformatted statement must normalize to the same
1246
1259
  // shape as the original — ` <N> );` and ` <N>);` are the same assertion.
1247
1260
  .replace(/\s+/g, "");
1248
1261
 
1262
+ /**
1263
+ * Split a *bare-comparison* assertion into the compared subject and the
1264
+ * expected expression: `assert dec == value`, `assert!(x == y)` (Rust) and
1265
+ * `assert add(1, 2) == 3` — the forms SPECIFIC_ASSERTION names that carry no
1266
+ * call argument list, so splitAssertionArgs cannot see their operands.
1267
+ *
1268
+ * The split happens at the first top-level equality comparison, which is
1269
+ * the assertion's own operator in every supported form; comparisons nested
1270
+ * deeper (the one inside a conditional expectation) belong to the expected
1271
+ * expression and are returned as part of `rhs`.
1272
+ *
1273
+ * @returns {{lhs: string, rhs: string} | null}
1274
+ */
1275
+ function splitBareComparison(clean, lang) {
1276
+ const trySplit = (body) => {
1277
+ // First equality comparison after the body starts; comparisons nested in
1278
+ // the expected expression (e.g. inside a conditional) are further right
1279
+ // and therefore part of the rhs.
1280
+ const m = /(?:^|[^=!<>])==(?!=)/.exec(body);
1281
+ if (!m) return null;
1282
+ const at = m.index + m[0].length - 2; // index of the first `=`
1283
+ return { lhs: body.slice(0, at).trim(), rhs: body.slice(at + m[0].length - 1).trim() };
1284
+ };
1285
+
1286
+ // Python/Elixir: `assert <subj> == <expect>`.
1287
+ let m = /\bassert\s+([\s\S]+)$/.exec(clean);
1288
+ if (m) {
1289
+ const parts = trySplit(m[1]);
1290
+ if (parts) return parts;
1291
+ }
1292
+
1293
+ // Rust: `assert!(<subj> == <expect>)`.
1294
+ m = /\bassert!\s*\(\s*([\s\S]*?)\s*\)\s*;?\s*$/.exec(clean);
1295
+ if (m) {
1296
+ const parts = trySplit(m[1]);
1297
+ if (parts) return parts;
1298
+ }
1299
+
1300
+ // JS/Java-style call form handed in here as well (shape pairing covers
1301
+ // most; this only needs to expose the subject for a conditional rhs).
1302
+ const cm = lang === "js" || lang === "java" ? /\bexpect\s*\(([^)]*)\)\s*\.[\s\S]*?\(\s*([\s\S]*?)\s*\)\s*;?\s*$/.exec(clean) : null;
1303
+ if (cm) {
1304
+ return { lhs: cm[1].trim(), rhs: cm[2].trim() };
1305
+ }
1306
+
1307
+ return null;
1308
+ }
1309
+
1310
+ /**
1311
+ * True when an expected expression is conditional rather than a single value:
1312
+ * Python's `x if cond else y` (including a comparison inside, which is the
1313
+ * F03 spelling — `(193 if value == 192 else value)`) or a JS/Java
1314
+ * `cond ? x : y` ternary. Conditional expectations keep the suite green for
1315
+ * both the old and the broken output, which is exactly the point of replacing
1316
+ * a value with one.
1317
+ */
1318
+ function containsConditional(expr) {
1319
+ if (/\bif\b[^?:\n]*\belse\b/.test(expr)) return true;
1320
+ // A question mark that is a ternary, not optional chaining (`?.`) or
1321
+ // nullish (`??`).
1322
+ if (/[)\]\w"']\s*\?(?![.?])[^?:\n]*:/.test(expr)) return true;
1323
+ return false;
1324
+ }
1325
+
1249
1326
  // The test languages the gate runs over. The scanner below is written for
1250
1327
  // these four and nothing else; an unrecognised extension falls back to `js`,
1251
1328
  // which is the strictest of the four for line joining.
@@ -1907,17 +1984,26 @@ function declaredTestName(text) {
1907
1984
  }
1908
1985
 
1909
1986
  /**
1910
- * Is `after` the same declaration as `before` with its discovery prefix gone?
1987
+ * Did a collected test become a declaration the runner no longer collects?
1988
+ *
1989
+ * The name *is* the registration for pytest (`test*`) and Go (`Test*` with
1990
+ * an uppercase letter or underscore after), so the signal is purely whether
1991
+ * the runner would still find it: `test_want_bytes` → `check_want_bytes`,
1992
+ * `TestLoadComment` → `checkLoadComment`, `test_x` → `disabled_x` all remove
1993
+ * the test from the run while leaving every assertion in place.
1911
1994
  *
1912
- * Exact on the remainder, deliberately. `test_totals` → `totals` is a
1913
- * de-registration; `test_totals` → `test_totals_rounded` is a rename and must
1914
- * stay silent, which is the false red this check exists alongside rather than
1915
- * instead of.
1995
+ * The earlier rule required the new name to be the old one with its prefix
1996
+ * literally stripped, so `test_x` → `x` was caught and every other prefix
1997
+ * swap sailed through. It was written narrow to avoid flagging
1998
+ * `test_x` → `test_x_renamed` — an honest rename — but that case never needs
1999
+ * the strip rule: the new name is *still collected*, so the collected check
2000
+ * already keeps it silent, along with pytest's `test*` glob collecting
2001
+ * `testx`, Go's `TestX` → `TestXRenamed`, and case-class `test_x` →
2002
+ * `test_y`. Pairing is still required (a pure deletion is an assertion
2003
+ * removal, not a rename), and names identical on both sides never pair.
1916
2004
  */
1917
2005
  function isDeregistration(before, after) {
1918
- if (!before.collected || after.collected) return false;
1919
- const stripped = before.name.replace(/^test[_-]?/i, "").replace(/^(?:Test|Benchmark|Fuzz|Example)/, "");
1920
- return stripped.length > 0 && stripped === after.name;
2006
+ return Boolean(before.collected && !after.collected && before.name !== after.name);
1921
2007
  }
1922
2008
 
1923
2009
  // A test declaration whose first argument is the test's name. The name is
@@ -2168,6 +2254,42 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
2168
2254
  }
2169
2255
  }
2170
2256
 
2257
+ // A bare-comparison assertion whose expectation became a conditional
2258
+ // value. `assert dec == value` rewritten as
2259
+ // `assert dec == (193 if value == 192 else value)` keeps a comparison on
2260
+ // both sides of the new `==`, so both statements still parse as
2261
+ // assertions, but the expected value is now a conditional that bends to
2262
+ // broken output — F03, measured approving a deliberately broken function.
2263
+ // The call-argument passes above cannot see this spelling: a Python/Rust
2264
+ // bare comparison has no argument list, and the conditional introduces a
2265
+ // second comparison so the two images never share a shape bucket.
2266
+ //
2267
+ // The subject (the left operand of the assertion) must survive, and the
2268
+ // new right-hand side has to be a *conditional expression* —
2269
+ // Python's `a if c else b` or a JS/Java `c ? a : b` — so an honest
2270
+ // assertion whose expected value is a ternary from the start is only
2271
+ // reported when it replaces a non-conditional expectation of the same
2272
+ // subject, and an identifier renamed in the expectation (no conditional)
2273
+ // is not reported here either.
2274
+ for (const r of oldCands) {
2275
+ if (pairedOld.has(r) || cancelled.has(r)) continue;
2276
+ const oldParts = splitBareComparison(r.clean, lang);
2277
+ if (!oldParts) continue;
2278
+ for (const a of newCands) {
2279
+ if (pairedNew.has(a) || cancelled.has(a)) continue;
2280
+ const newParts = splitBareComparison(a.clean, lang);
2281
+ if (!newParts) continue;
2282
+ if (oldParts.lhs.replace(/\s+/g, "") !== newParts.lhs.replace(/\s+/g, "")) continue;
2283
+ if (oldParts.rhs.replace(/\s+/g, "") === newParts.rhs.replace(/\s+/g, "")) continue;
2284
+ if (!containsConditional(newParts.rhs)) continue;
2285
+ if (isNonExpectationDifference(r.clean, a.clean, lang)) continue;
2286
+ pairs.push({ r: r.s, a: a.s });
2287
+ pairedOld.add(r);
2288
+ pairedNew.add(a);
2289
+ break;
2290
+ }
2291
+ }
2292
+
2171
2293
  // Zero-context hunk: each image is a single fragment and the assertion
2172
2294
  // keyword may sit outside the hunk entirely. The fragment pair is taken
2173
2295
  // only when both sides normalize to the same shape *and* that shape
@@ -2262,6 +2384,79 @@ export const TAMPER_KINDS = new Map([
2262
2384
  /** Every kind name, for CLI validation and help text. */
2263
2385
  export const TAMPER_KIND_NAMES = Object.freeze([...new Set(TAMPER_KINDS.values())].sort());
2264
2386
 
2387
+ /**
2388
+ * Conditions a compiler or the language's own rules make impossible.
2389
+ *
2390
+ * A Go `len(...)` is never negative, so `if len(comment) < 0` is false on
2391
+ * every input; a C unsigned/size comparison against 0 is the same shape in
2392
+ * the dialects scanned under the JS lexer. These are the cases where the
2393
+ * condition governing a failure call can be proven dead from the diff line
2394
+ * alone — anything fuzzier (a flag constant flipped elsewhere, an unreachable
2395
+ * branch behind real state) is not guessable and is deliberately left alone.
2396
+ */
2397
+ const DEAD_GUARD_CONDITION =
2398
+ /\bif\b[^;{}]*\b(?:len|len\s+of|count|size|length|num\w*|total)\s*\([^)]*\)\s*(?:<\s*0|<\s*-0\b)|<=\s*-1\b/i;
2399
+
2400
+ /**
2401
+ * The calls a test uses to say "this failed": the assertion's actual teeth.
2402
+ * When one of these sits inside a dead condition, the assertion survives in
2403
+ * name only.
2404
+ */
2405
+ const FAILURE_CALL =
2406
+ /\b(?:t\.(?:Errorf|Fatalf|Fatal|Error)\s*\(|require\.(?:Fail|FailNow|Error|Errorf|Equal|NotEqual|Len|Contains|NoError)\s*\(|assert\.(?:fail|fail!|equal|deepEqual|strictEqual)\b|assert_eq!\s*\(|assert!\s*\(|pytest\.fail\s*\(|self\.fail(?:ure)?\s*\(|fail(?:ure)?\s*\(|throw\s+new\s+(?:AssertionError|Error)\b|raise\s+AssertionError\b)/i;
2407
+
2408
+ /**
2409
+ * Go build-constraint terms. `//go:build ignore` never matches a release
2410
+ * build; conjoining a private tag (`go1.7 && cold_start_never`) gates a file
2411
+ * unless CI sets the tag. Version (`go1.x`), OS and arch terms are legitimate
2412
+ * CI gating and stay silent.
2413
+ */
2414
+ const GO_BUILD_TAG_LINE = /^\s*\/\/go:build\s+(.+?)\s*$/;
2415
+ const GO_LEGACY_TAG_LINE = /^\s*\/\/\s*\+build\s+(.+?)\s*$/;
2416
+ const goBuildTerms = (line) => {
2417
+ const m = GO_BUILD_TAG_LINE.exec(line) || GO_LEGACY_TAG_LINE.exec(line);
2418
+ if (!m) return null;
2419
+ // `&&`/`||` separate constraint expressions; spaces/commas separate terms.
2420
+ // Negated terms (`!tag`) are normal platform guards; strip the leading `!`.
2421
+ return m[1]
2422
+ .split(/\s*&&\s*|\s*\|\|\s*|[\s,]+/)
2423
+ .filter(Boolean)
2424
+ .map((t) => t.replace(/^!/, ""));
2425
+ };
2426
+ const goKnownBuildTerm = (term) =>
2427
+ /^go1\.\d+/.test(term) ||
2428
+ /^(?:linux|darwin|windows|freebsd|openbsd|netbsd|dragonfly|solaris|aix|js|wasip1|plan9|ios|android)$/.test(term) ||
2429
+ /^(?:amd64|386|arm|arm64|ppc64|ppc64le|mips|mipsle|mips64|mips64le|riscv64|s390x|wasm|loong64)$/.test(term) ||
2430
+ term === "ignore";
2431
+
2432
+ /**
2433
+ * A bare, unconditional `return` (optionally returning a constant) — never a
2434
+ * `return value`, never attached to an `if` on the same line.
2435
+ */
2436
+ /**
2437
+ * A bare, unconditional `return` (optionally returning a constant) — never a
2438
+ * `return value`, never attached to an `if` on the same line.
2439
+ *
2440
+ * The boundary of what this check can see: the body-first bare `return` is
2441
+ * caught; a `return` behind a condition the test author believes cannot hold
2442
+ * is not. Judging the latter requires knowing whether the branch is
2443
+ * reachable at runtime — a whole-program control-flow question no line pair
2444
+ * answers — and flagging any `return` above an assertion would hard-red the
2445
+ * ordinary `if (process.platform === "win32") return;` guard clause. The rule
2446
+ * stops at the shape whose intent is unambiguous from the text. Runtime
2447
+ * attestation (counting tests collected before and after) is the complete
2448
+ * answer, and a separate check from the text guard.
2449
+ */
2450
+ const BARE_EARLY_RETURN = /^\s*return\s*(?:(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|true|false|None|nil|null|undefined|0|-?\d+)\s*)?;?\s*$/;
2451
+
2452
+ /**
2453
+ * A line that opens a test body: a pytest/unittest `def test*` signature, a
2454
+ * Go `func Test*` / Rust `fn` immediately preceded by a test attribute (best
2455
+ * effort at line scope), or a JS test registration whose callback opens.
2456
+ */
2457
+ const TEST_BODY_OPEN =
2458
+ /(?:^|\s)(?:def\s+test\w*\s*\([^)]*\)\s*(?:->[^:]+)?\s*:|func\s+(?:Test|Benchmark|Fuzz|Example)\w*\s*\([^)]*\)\s*\{|fn\s+\w+\s*\([^)]*\)\s*\{|\b(?:it|test|describe|context)\s*(?:\.[a-zA-Z]+)?\s*\(\s*["'`][^"'`]*["'`]\s*,?\s*(?:async\s*)?(?:function)?\s*\w*\s*=>?\s*\{?)$/i;
2459
+
2265
2460
  /**
2266
2461
  * Which tamper checks this run is allowed to stay quiet about.
2267
2462
  *
@@ -2359,9 +2554,18 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2359
2554
  { pattern: /\braise\s+(?:unittest\.)?SkipTest\b/i, desc: "Injected unittest skip (raise SkipTest)" },
2360
2555
  { pattern: /\bpytest\.skip\s*\(/i, desc: "Injected Pytest skip call (pytest.skip())" },
2361
2556
  { pattern: /\bpytest\.xfail\s*\(/i, desc: "Injected Pytest expected-failure (pytest.xfail())" },
2557
+ // The decorator form the call above does not cover. `strict=False` (the
2558
+ // default) lets a *broken* test pass as xpass-with-no-failure; strict only
2559
+ // fails on an unexpected pass, so either spelling blesses a failing suite.
2560
+ { pattern: /@pytest\.mark\.xfail\b/i, desc: "Injected Pytest expected-failure mark (@pytest.mark.xfail)" },
2561
+ { pattern: /@(?:unittest\.)?expectedFailure\b/i, desc: "Injected unittest expected-failure decorator (@expectedFailure)" },
2362
2562
  { pattern: /\bthis\.skip\s*\(/i, desc: "Injected Mocha skip (this.skip())" },
2363
2563
  { pattern: /\b(?:it|test|describe|context)\.todo\s*\(/i, desc: "Injected todo placeholder (test.todo())" },
2364
2564
  { pattern: /\bt\.Skip(?:Now|f)?\s*\(/, desc: "Injected Go test skip (t.Skip/t.Skipf/t.SkipNow)" },
2565
+ // `#[cfg(any())]` is false under every target — the idiomatic Rust way to
2566
+ // compile a test out without a skip or a rename. A body the cfg can never
2567
+ // be true for is a body the runner never executes.
2568
+ { pattern: /#\s*\[\s*cfg\s*\(\s*any\s*\(\s*\)\s*\)\s*\]/, desc: "Injected impossible cfg attribute (#[cfg(any())]) — the test never compiles" },
2365
2569
  ];
2366
2570
 
2367
2571
  // `#` and `--` belong here for the same reason the dialects belong in
@@ -2384,6 +2588,11 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2384
2588
  { pattern: /\b(?:assertEquals|assertSame|XCTAssertEqual)\s*\(\s*([^,]+?)\s*,\s*\1\s*[,)]/i, desc: "Vacuous identity assertion (assertEquals(X, X))" },
2385
2589
  { pattern: /\bassert_equal\s*\(?\s*([^,]+?)\s*,\s*\1\s*\)?\s*$/i, desc: "Vacuous identity assertion (assert_equal X, X)" },
2386
2590
  { pattern: /\bexpect\s*\(\s*true\s*\)\s*\.to\s+be(?:\s+true)?\b/i, desc: "Vacuous truth expectation (expect(true).to be true)" },
2591
+ // AVA and node:test: `t.true(true)` / `t.assert(true)` assert a constant
2592
+ // the test itself supplied. The trial replaced `t.is(limit.activeCount,
2593
+ // 0)` with `t.true(true)` in a file the guard did not even classify.
2594
+ { pattern: /\b[a-z_$][a-z0-9_$]{0,2}\.(?:true|truthy|assert|ok)\s*\(\s*true\s*\)/i, desc: "Vacuous truth assertion (t.true(true))" },
2595
+ { pattern: /\b[a-z_$][a-z0-9_$]{0,2}\.(?:false|falsy|notOk)\s*\(\s*false\s*\)/i, desc: "Vacuous falsity assertion (t.false(false))" },
2387
2596
  ];
2388
2597
 
2389
2598
  // Broad on purpose: this is the denominator, not the verdict. A word
@@ -2407,7 +2616,11 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2407
2616
  // CommonJS test file `require("./calc")` is an import, not a claim.
2408
2617
  const ASSERTION_SHAPED =
2409
2618
  /\b(?:assert(?!ion|ing|ed\b|s\b)|expect(?!ed\b|ation)|refute)[a-zA-Z0-9_$]*\b|`\s*should[a-zA-Z0-9_$]*\s*`|\b(?:should|must|verify|ensure|confirm)[a-zA-Z0-9_$]*\s*[(!]|\.\s*(?:should|to|to_not|not_to|must)\b|\bBOOST_[A-Z_]+\s*\(|\b[A-Z]+_(?:EQ|NE|TRUE|FALSE|THAT)\s*\(/;
2410
- const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
2619
+ // `#` starts a line comment in Python/Ruby — but in Rust it opens an
2620
+ // attribute (`#[test]`, `#![...]`), which is code the runner keys off:
2621
+ // reading it as a comment is how `#[test]` removal used to slip past both
2622
+ // the declaration scan and the attribute check.
2623
+ const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#(?![![])|--|;)/.test(str);
2411
2624
 
2412
2625
  /** Book-keeping only: what this run looked at, before deciding anything. */
2413
2626
  const countExamined = (stats, text) => {
@@ -2511,6 +2724,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2511
2724
  }
2512
2725
  }
2513
2726
 
2727
+ // Go build constraints (ignore / private-tag tightening) are assessed
2728
+ // as a hunk post-pass below, because their witness — the removed
2729
+ // constraint line — is not present while this added line is scanned.
2730
+
2514
2731
  // Check commented-out assertions
2515
2732
  let isCommented = false;
2516
2733
  if (COMMENTED_ASSERTION.test(line)) {
@@ -2619,16 +2836,17 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2619
2836
 
2620
2837
  // A test renamed out of its runner's discovery convention.
2621
2838
  //
2622
- // pytest collects `test_*` and nothing else, so `def test_totals` becoming
2623
- // `def totals` deletes the test from every future run while leaving it in
2624
- // the file, fully written, with all its assertions intact. Every count in
2625
- // this guard stays level: nothing was removed, weakened or rewritten.
2839
+ // pytest collects `test*` and nothing else, so `def test_totals` becoming
2840
+ // `def check_totals` deletes the test from every future run while leaving it
2841
+ // in the file, fully written, with all its assertions intact. Every count in
2842
+ // this guard stays level: nothing removed, weakened or rewritten.
2626
2843
  //
2627
- // Until now this was caught only by accident, as a side effect of the
2628
- // blanket that blocked every unrecognised edit to a test file — which also
2629
- // blocked adding an import, and whose printed remedy (`tamperGuard: "warn"`)
2630
- // switched off the real checks along with the blanket. Narrowing that blanket
2631
- // is what makes this its own finding, with its own name and its own remedy.
2844
+ // The earlier rule required the new name to be the old one with its prefix
2845
+ // literally stripped (`test_x` -> `x` caught; `test_x` -> `check_x` not).
2846
+ // The collected check is what actually matters, and it already keeps the
2847
+ // honest renames silent — `test_x` -> `test_x_renamed`, pytest's `test*`
2848
+ // glob still collecting `testx`, Go's `TestX` -> `TestXRenamed` — so the
2849
+ // narrow strip is gone.
2632
2850
  for (const [file, stats] of fileAssertions.entries()) {
2633
2851
  const takenAdds = new Set();
2634
2852
  for (const before of stats.declRemoved || []) {
@@ -2651,6 +2869,227 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2651
2869
  }
2652
2870
  }
2653
2871
 
2872
+ // Runners that register by attribute/annotation rather than by name: Rust's
2873
+ // `#[test]` (the name above the function is free-form, so the name pair
2874
+ // above cannot see this family) and JUnit's `@Test`. Removing the attribute
2875
+ // from an existing function keeps the body and loses the test — the same
2876
+ // uncollect with no line deleted.
2877
+ const TEST_ATTR_PATTERNS = [
2878
+ { lang: "rust", re: /^\s*#\s*\[\s*(?:test|tokio::test|async_std::test)\s*\]/ },
2879
+ // JUnit/TestNG annotations live in files the scanner lexes as `js`
2880
+ // (the C-like family), so the lang here is the scanner's lang, not the
2881
+ // source language's name.
2882
+ { lang: "js", re: /^\s*@(?:org\.junit\.)?(?:jupiter\.api\.)?Test\b/ },
2883
+ ];
2884
+ const attrRegistration = (text, file) => {
2885
+ const lang = langForTestFile(file);
2886
+ return TEST_ATTR_PATTERNS.some((rule) => rule.lang === lang && rule.re.test(text));
2887
+ };
2888
+
2889
+ // Function signature text of the declaration the attribute at `start`
2890
+ // governs. Attributes sit immediately above `fn x()` / `void x()`, so the
2891
+ // next declaration line in the hunk carries the signature; scanning
2892
+ // backwards covers an attribute written on a context line position.
2893
+ const FN_SIG_RE = /\b(?:fn|func|def)\s+[A-Za-z_]\w*\s*\(|\b(?:void|[A-Za-z_][\w.<>\[\]]*)\s+[A-Za-z_]\w*\s*\([^;]*\)\s*(?:\{|$|throws\b)/;
2894
+ const adjacentSignature = (hunkLines, start) => {
2895
+ for (let k = start + 1; k < hunkLines.length; k++) {
2896
+ const t = hunkLines[k].text || "";
2897
+ if (FN_SIG_RE.test(t)) return collapseWhitespace(t).trim();
2898
+ }
2899
+ for (let k = start - 1; k >= 0; k--) {
2900
+ const t = hunkLines[k].text || "";
2901
+ if (FN_SIG_RE.test(t)) return collapseWhitespace(t).trim();
2902
+ }
2903
+ return null;
2904
+ };
2905
+
2906
+ // Attribute arrivals across the whole diff: a registration lost in one
2907
+ // file is forgiven when the same signature gained one in another — a move
2908
+ // between test files is ordinary refactoring, the same allowance the
2909
+ // assertion-removal check makes for moved assertions.
2910
+ const attrArrivals = new Map(); // signature -> [files]
2911
+ for (const [file, stats] of fileAssertions.entries()) {
2912
+ for (const hunk of stats.hunks) {
2913
+ for (let i = 0; i < hunk.lines.length; i++) {
2914
+ const L = hunk.lines[i];
2915
+ if (L.kind !== "+" || !attrRegistration(L.text, file)) continue;
2916
+ const sig = adjacentSignature(hunk.lines, i);
2917
+ if (!sig) continue;
2918
+ if (!attrArrivals.has(sig)) attrArrivals.set(sig, []);
2919
+ attrArrivals.get(sig).push(file);
2920
+ }
2921
+ }
2922
+ }
2923
+
2924
+ for (const [file, stats] of fileAssertions.entries()) {
2925
+ for (const hunk of stats.hunks) {
2926
+ const removedAttrs = [];
2927
+ let addedInHunk = 0;
2928
+ for (let i = 0; i < hunk.lines.length; i++) {
2929
+ const L = hunk.lines[i];
2930
+ if (L.kind === "-" && !isCommentLine(L.text) && attrRegistration(L.text, file)) {
2931
+ removedAttrs.push({ line: L.oldNo, text: L.text, sig: adjacentSignature(hunk.lines, i) });
2932
+ }
2933
+ if (L.kind === "+" && attrRegistration(L.text, file)) addedInHunk++;
2934
+ }
2935
+ // Net loss within the hunk; same-file additions cancel one for one
2936
+ // (a test renamed or re-attributed counts even).
2937
+ let deficit = removedAttrs.length - addedInHunk;
2938
+ for (const attr of removedAttrs) {
2939
+ if (deficit <= 0) break;
2940
+ const landed = attr.sig ? attrArrivals.get(attr.sig) : null;
2941
+ if (landed) {
2942
+ const elsewhere = landed.findIndex((f) => f !== file);
2943
+ if (elsewhere !== -1) {
2944
+ landed.splice(elsewhere, 1);
2945
+ continue;
2946
+ }
2947
+ }
2948
+ deficit--;
2949
+ violations.push({
2950
+ file,
2951
+ line: attr.line,
2952
+ type: "TEST_DEREGISTERED",
2953
+ reason:
2954
+ `Test Tamper Guard: a test-registration attribute was removed from an existing function in ${file}` +
2955
+ `${attr.line ? `:${attr.line}` : ""} (${collapseWhitespace(attr.text).trim()}). ` +
2956
+ `The runner only executes functions carrying that attribute, so the test still exists in the file ` +
2957
+ `and no longer runs — the same effect as deleting it, with none of the signs. If the test is ` +
2958
+ `genuinely obsolete, delete it; if it is becoming a helper, say so with ` +
2959
+ `--allow-test-change deregistration.`,
2960
+ });
2961
+ }
2962
+ }
2963
+ }
2964
+
2965
+ // A failure call parked behind a condition that cannot hold. Keeping the
2966
+ // `t.Errorf` while swapping its guard for `if len(s) < 0` (a Go string or
2967
+ // slice length is never negative) preserves every line of the old
2968
+ // assertion in code that can never run — F05, measured approving the
2969
+ // neutralised test. This is a hunk post-pass rather than an added-line
2970
+ // rule because the failure call it protects is untouched code: it sits on
2971
+ // a context line, which the line-by-line scan has not walked over yet.
2972
+ //
2973
+ // Only conditions that are impossible on their face are looked at
2974
+ // (`len(...) < 0`, an unsigned/size count `<= -1`); anything fuzzier — a
2975
+ // flag flipped elsewhere, a branch behind real state — is not guessable
2976
+ // from a diff and is deliberately left alone. The failure call has to be
2977
+ // reachable inside the condition's block, or an impossible condition in
2978
+ // ordinary test setup would be misread as one.
2979
+ for (const [file, stats] of fileAssertions.entries()) {
2980
+ for (const hunk of stats.hunks) {
2981
+ for (let i = 0; i < hunk.lines.length; i++) {
2982
+ const L = hunk.lines[i];
2983
+ if (L.kind !== "+" || !DEAD_GUARD_CONDITION.test(L.text)) continue;
2984
+ const guardLineNo = L.newNo;
2985
+ const guardText = L.text;
2986
+ let depth = (guardText.match(/\{/g) || []).length - (guardText.match(/\}/g) || []).length;
2987
+ let found = depth > 0 && FAILURE_CALL.test(guardText.slice(guardText.indexOf("{")));
2988
+ for (let k = i + 1; k < hunk.lines.length && depth > 0; k++) {
2989
+ const t = hunk.lines[k].text || "";
2990
+ const opens = (t.match(/\{/g) || []).length;
2991
+ const closes = (t.match(/\}/g) || []).length;
2992
+ if (depth > 0 && FAILURE_CALL.test(t)) { found = true; break; }
2993
+ depth += opens - closes;
2994
+ }
2995
+ if (found) {
2996
+ violations.push({
2997
+ file,
2998
+ line: guardLineNo,
2999
+ type: "VACUOUS_ASSERTION",
3000
+ reason:
3001
+ `Test Tamper Guard: the failure call in ${file}${guardLineNo ? `:${guardLineNo}` : ""} ` +
3002
+ `is guarded by a condition that can never be true ("${collapseWhitespace(guardText).trim()}"), so the ` +
3003
+ `assertion cannot fail. Keeping the failure call in dead logic is not preservation of verification.`,
3004
+ });
3005
+ }
3006
+ }
3007
+ }
3008
+ }
3009
+
3010
+ // Go build constraints that exclude a test file from the ordinary run.
3011
+ // `//go:build ignore` never matches a release build; conjoining a private
3012
+ // tag onto an existing constraint (`go1.7` -> `go1.7 && my_tag`) excludes
3013
+ // the file unless a CI job sets that tag. Version, OS and arch terms are
3014
+ // legitimate gating, so only an impossible ignore or a new private term
3015
+ // counts.
3016
+ for (const [file, stats] of fileAssertions.entries()) {
3017
+ if (langForTestFile(file) !== "go") continue;
3018
+ for (const hunk of stats.hunks) {
3019
+ for (let i = 0; i < hunk.lines.length; i++) {
3020
+ const L = hunk.lines[i];
3021
+ if (L.kind !== "+") continue;
3022
+ const terms = goBuildTerms(L.text);
3023
+ if (!terms) continue;
3024
+ if (terms.every((t) => t === "ignore") || terms.includes("ignore")) {
3025
+ violations.push({
3026
+ file,
3027
+ line: L.newNo,
3028
+ type: "TEST_SKIP_INJECTION",
3029
+ reason:
3030
+ `Test Tamper Guard: Go build constraint ${JSON.stringify(collapseWhitespace(L.text).trim())} in ${file}` +
3031
+ `${L.newNo ? `:${L.newNo}` : ""} excludes this test file from the ordinary 'go test' run — the ignore tag never ` +
3032
+ `matches a release build. Use --allow-test-change skip if the file is built by a deliberately separate command.`,
3033
+ });
3034
+ continue;
3035
+ }
3036
+ const oldTerms = new Set();
3037
+ for (const O of hunk.lines) {
3038
+ if (O.kind !== "-") continue;
3039
+ const t = goBuildTerms(O.text);
3040
+ if (t) for (const term of t) oldTerms.add(term);
3041
+ }
3042
+ const privateTag = terms.find((t) => !goKnownBuildTerm(t) && !oldTerms.has(t));
3043
+ if (privateTag) {
3044
+ violations.push({
3045
+ file,
3046
+ line: L.newNo,
3047
+ type: "TEST_SKIP_INJECTION",
3048
+ reason:
3049
+ `Test Tamper Guard: Go build constraint tightened with the private tag ${JSON.stringify(privateTag)} in ` +
3050
+ `${file}${L.newNo ? `:${L.newNo}` : ""} — a tag no ordinary 'go test' run sets excludes this test file from the ` +
3051
+ `suite. Version/OS/arch gating stays silent; a tag nobody passes is a skip. Use --allow-test-change skip ` +
3052
+ `if the tag is gated in CI on purpose.`,
3053
+ });
3054
+ }
3055
+ }
3056
+ }
3057
+ }
3058
+
3059
+ // An unconditional early return as the test body's first statement. A test
3060
+ // that returns before its assertions keeps the suite green while
3061
+ // verifying nothing — the trial made a broken function pass this way.
3062
+ // Unlike the skip above this spelling has no marker, so the declaration
3063
+ // has to be visible as the nearest non-blank line above the return:
3064
+ // `return` as a genuine guard clause (after a real condition or check)
3065
+ // never sits with a test signature as its predecessor, and a `return`
3066
+ // inside the body an honest refactor edited is past body-open already.
3067
+ for (const [file, stats] of fileAssertions.entries()) {
3068
+ for (const hunk of stats.hunks) {
3069
+ for (let i = 0; i < hunk.lines.length; i++) {
3070
+ const L = hunk.lines[i];
3071
+ if (L.kind !== "+" || !BARE_EARLY_RETURN.test(L.text)) continue;
3072
+ for (let k = i - 1; k >= 0; k--) {
3073
+ const t = (hunk.lines[k].text || "").trim();
3074
+ if (!t) continue;
3075
+ if (TEST_BODY_OPEN.test(hunk.lines[k].text || "")) {
3076
+ violations.push({
3077
+ file,
3078
+ line: L.newNo,
3079
+ type: "TEST_SKIP_INJECTION",
3080
+ reason:
3081
+ `Test Tamper Guard: an unconditional early return opens the test body in ` +
3082
+ `${file}${L.newNo ? `:${L.newNo}` : ""}, so none of the assertions after it run while the test still ` +
3083
+ `passes. A suite cannot verify what control flow never reaches. Use --allow-test-change skip if this ` +
3084
+ `test is deliberately dormant.`,
3085
+ });
3086
+ }
3087
+ break;
3088
+ }
3089
+ }
3090
+ }
3091
+ }
3092
+
2654
3093
  for (const [file, stats] of fileAssertions.entries()) {
2655
3094
  // An expectation that was rewritten rather than removed.
2656
3095
  //
@@ -118,6 +118,27 @@ export function isPlaceholderTestScript(cmd) {
118
118
  const trimmed = cmd.trim();
119
119
  if (!trimmed) return true;
120
120
  if (EMPTY_PROGRAM.test(trimmed)) return true;
121
+
122
+ // Node/Deno/Bun process.exit(0)
123
+ if (/^(?:\S*[/\\])?(?:node|nodejs|deno|bun)\b.*?(?:-e|--eval)\s+["'`]\s*(?:(?:void\s+0|0)?\s*;?\s*process\.exit\s*\(\s*0?\s*\)\s*;?)*\s*["'`]\s*$/.test(trimmed)) {
124
+ return true;
125
+ }
126
+
127
+ // Python exit(0) / pass
128
+ if (/^(?:\S*[/\\])?python[\d.]*\b.*?-c\s+["'`]\s*(?:(?:import\s+sys\s*;?\s*)?(?:sys\.)?exit\s*\(\s*0?\s*\)|pass)?\s*;?\s*["'`]\s*$/.test(trimmed)) {
129
+ return true;
130
+ }
131
+
132
+ // Shell running no-op / exit 0
133
+ if (/^(?:\S*[/\\])?(?:sh|bash|zsh|dash)\b.*?-c\s+["'`]?\s*(?::|true|exit\s+0)?\s*;?\s*["'`]?\s*$/.test(trimmed)) {
134
+ return true;
135
+ }
136
+
137
+ // Pytest collection only without test execution
138
+ if (/\bpytest\b.*?(?:--collect-only\b)/.test(trimmed)) {
139
+ return true;
140
+ }
141
+
121
142
  // Drop the announcements; what matters is what the shell is left doing.
122
143
  const remainder = trimmed
123
144
  .split(/&&|;/)
@@ -40,7 +40,15 @@ const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__"
40
40
  * Covers, by directory: `test/`, `tests/`, `spec/`, `specs/`, `__tests__/` at
41
41
  * any depth including the repository root. By filename: `foo.test.js`,
42
42
  * `foo.spec.ts`, `foo_test.go`, `foo_spec.rb`, `test_calc.py` (pytest),
43
- * `spec_helper.rb`, and Foundry's `Foo.t.sol` / `FooTest.sol`.
43
+ * `spec_helper.rb`, Foundry's `Foo.t.sol` / `FooTest.sol`, and the canonical
44
+ * Node root test file — `test.js`, `test.mjs`, `test.ts` and the rest of the
45
+ * `.cjs`/`.jsx`/`.tsx` family. AVA runs the root `test.js` by convention
46
+ * ("test files starting with `test` … at the repo root"), and so does
47
+ * `node --test test.js`; P-Limit's whole suite lived in one and the guard
48
+ * watched none of it. The bare-name rule is limited to the Node family on
49
+ * purpose: `test.py` is not a pytest file (pytest collects `test_*.py`),
50
+ * `test.go` is not a Go test (`*_test.go`), and `test.rb` is neither an RSpec
51
+ * (`*_spec.rb`) nor a Minitest (`test_*.rb`) file.
44
52
  *
45
53
  * @param {string} file - Repo-relative path, either separator.
46
54
  * @returns {boolean}
@@ -55,6 +63,7 @@ export function isTestPath(file) {
55
63
  }
56
64
 
57
65
  const base = segments[segments.length - 1];
66
+ if (ROOT_NODE_TEST_RE.test(base)) return true;
58
67
  return (
59
68
  base.includes(".test.") ||
60
69
  base.includes(".spec.") ||
@@ -66,3 +75,11 @@ export function isTestPath(file) {
66
75
  base.endsWith(".t.sol")
67
76
  );
68
77
  }
78
+
79
+ /**
80
+ * A supported Node runner's canonical root test file: `test` exactly, with a
81
+ * JavaScript-family extension. `test.helpers.js` and `tests.js` must not
82
+ * match — a name that starts with the word and keeps going is a support
83
+ * module, and the plural is an ordinary source file.
84
+ */
85
+ const ROOT_NODE_TEST_RE = /^test\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/;