jules-orchestrator-kit 0.70.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.
@@ -1875,6 +1952,60 @@ function splitTrailingMessage(clean) {
1875
1952
  return { head: clean.slice(0, lastComma), msg: tail };
1876
1953
  }
1877
1954
 
1955
+ /**
1956
+ * Test declarations that a runner finds by the *name* of the function.
1957
+ *
1958
+ * pytest collects `def test_*`, Go collects `func Test*`, and unittest and
1959
+ * Minitest collect `def test_*` off the case class. For those runners the
1960
+ * name is not prose — it is the registration. Renaming `test_totals` to
1961
+ * `totals` deletes the test from the run as completely as removing the file,
1962
+ * and the diff shows a rename.
1963
+ *
1964
+ * Only these name-driven runners are listed. `it("...")`, `#[test]` and
1965
+ * `@Test` register by call, attribute or annotation, so renaming what they
1966
+ * declare removes nothing, and the ordinary rename rules already cover them.
1967
+ */
1968
+ const NAME_REGISTERED_DECLS = [
1969
+ { lang: "python", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/, discovered: /^test/i },
1970
+ { lang: "go", re: /^\s*func\s+([A-Za-z_]\w*)\s*\(/, discovered: /^(?:Test|Benchmark|Fuzz|Example)/ },
1971
+ ];
1972
+
1973
+ /**
1974
+ * The declared name on this line, and whether the runner would collect it.
1975
+ *
1976
+ * @returns {{ name: string, collected: boolean }|null}
1977
+ */
1978
+ function declaredTestName(text) {
1979
+ for (const rule of NAME_REGISTERED_DECLS) {
1980
+ const m = rule.re.exec(text);
1981
+ if (m) return { name: m[1], collected: rule.discovered.test(m[1]) };
1982
+ }
1983
+ return null;
1984
+ }
1985
+
1986
+ /**
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.
1994
+ *
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.
2004
+ */
2005
+ function isDeregistration(before, after) {
2006
+ return Boolean(before.collected && !after.collected && before.name !== after.name);
2007
+ }
2008
+
1878
2009
  // A test declaration whose first argument is the test's name. The name is
1879
2010
  // prose about the test, not a value the test asserts — `test("adds", ...)`
1880
2011
  // renamed to `test("adds positives", ...)` is the rename the diff says it is.
@@ -2123,6 +2254,42 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
2123
2254
  }
2124
2255
  }
2125
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
+
2126
2293
  // Zero-context hunk: each image is a single fragment and the assertion
2127
2294
  // keyword may sit outside the hunk entirely. The fragment pair is taken
2128
2295
  // only when both sides normalize to the same shape *and* that shape
@@ -2211,11 +2378,85 @@ export const TAMPER_KINDS = new Map([
2211
2378
  ["ASSERTION_REMOVAL", "removal"],
2212
2379
  ["ASSERTION_WEAKENED", "weakening"],
2213
2380
  ["ASSERTION_EXPECTATION_CHANGED", "expectation"],
2381
+ ["TEST_DEREGISTERED", "deregistration"],
2214
2382
  ]);
2215
2383
 
2216
2384
  /** Every kind name, for CLI validation and help text. */
2217
2385
  export const TAMPER_KIND_NAMES = Object.freeze([...new Set(TAMPER_KINDS.values())].sort());
2218
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
+
2219
2460
  /**
2220
2461
  * Which tamper checks this run is allowed to stay quiet about.
2221
2462
  *
@@ -2313,9 +2554,18 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2313
2554
  { pattern: /\braise\s+(?:unittest\.)?SkipTest\b/i, desc: "Injected unittest skip (raise SkipTest)" },
2314
2555
  { pattern: /\bpytest\.skip\s*\(/i, desc: "Injected Pytest skip call (pytest.skip())" },
2315
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)" },
2316
2562
  { pattern: /\bthis\.skip\s*\(/i, desc: "Injected Mocha skip (this.skip())" },
2317
2563
  { pattern: /\b(?:it|test|describe|context)\.todo\s*\(/i, desc: "Injected todo placeholder (test.todo())" },
2318
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" },
2319
2569
  ];
2320
2570
 
2321
2571
  // `#` and `--` belong here for the same reason the dialects belong in
@@ -2338,6 +2588,11 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2338
2588
  { pattern: /\b(?:assertEquals|assertSame|XCTAssertEqual)\s*\(\s*([^,]+?)\s*,\s*\1\s*[,)]/i, desc: "Vacuous identity assertion (assertEquals(X, X))" },
2339
2589
  { pattern: /\bassert_equal\s*\(?\s*([^,]+?)\s*,\s*\1\s*\)?\s*$/i, desc: "Vacuous identity assertion (assert_equal X, X)" },
2340
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))" },
2341
2596
  ];
2342
2597
 
2343
2598
  // Broad on purpose: this is the denominator, not the verdict. A word
@@ -2361,7 +2616,11 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2361
2616
  // CommonJS test file `require("./calc")` is an import, not a claim.
2362
2617
  const ASSERTION_SHAPED =
2363
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*\(/;
2364
- 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);
2365
2624
 
2366
2625
  /** Book-keeping only: what this run looked at, before deciding anything. */
2367
2626
  const countExamined = (stats, text) => {
@@ -2406,7 +2665,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2406
2665
  }
2407
2666
 
2408
2667
  if (!fileAssertions.has(currentFile)) {
2409
- fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0, hunks: [], examined: 0, recognised: 0, unreadable: [] });
2668
+ fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0, hunks: [], examined: 0, recognised: 0, unreadable: [], declRemoved: [], declAdded: [] });
2410
2669
  }
2411
2670
  const fileStats = fileAssertions.get(currentFile);
2412
2671
  if (pendingHunk) {
@@ -2419,6 +2678,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2419
2678
  const deletedText = line.slice(1);
2420
2679
  if (hunk) hunk.lines.push({ kind: "-", text: deletedText, oldNo: currentOldLineNo, newNo: null });
2421
2680
  countExamined(fileStats, deletedText);
2681
+ if (!isCommentLine(deletedText)) {
2682
+ const decl = declaredTestName(deletedText);
2683
+ if (decl) fileStats.declRemoved.push({ ...decl, line: currentOldLineNo, text: deletedText });
2684
+ }
2422
2685
  if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
2423
2686
  fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
2424
2687
  if (isSpecificAssertion(deletedText)) {
@@ -2430,6 +2693,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2430
2693
  const addedText = line.slice(1);
2431
2694
  if (hunk) hunk.lines.push({ kind: "+", text: addedText, oldNo: null, newNo: currentNewLineNo });
2432
2695
  countExamined(fileStats, addedText);
2696
+ if (!isCommentLine(addedText)) {
2697
+ const decl = declaredTestName(addedText);
2698
+ if (decl) fileStats.declAdded.push({ ...decl, line: currentNewLineNo, text: addedText });
2699
+ }
2433
2700
  let isVacuous = false;
2434
2701
 
2435
2702
  // Check skip injections
@@ -2457,6 +2724,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2457
2724
  }
2458
2725
  }
2459
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
+
2460
2731
  // Check commented-out assertions
2461
2732
  let isCommented = false;
2462
2733
  if (COMMENTED_ASSERTION.test(line)) {
@@ -2563,6 +2834,262 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2563
2834
  }
2564
2835
  }
2565
2836
 
2837
+ // A test renamed out of its runner's discovery convention.
2838
+ //
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.
2843
+ //
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.
2850
+ for (const [file, stats] of fileAssertions.entries()) {
2851
+ const takenAdds = new Set();
2852
+ for (const before of stats.declRemoved || []) {
2853
+ if (!before.collected) continue;
2854
+ const idx = (stats.declAdded || []).findIndex((after, i) => !takenAdds.has(i) && isDeregistration(before, after));
2855
+ if (idx === -1) continue;
2856
+ takenAdds.add(idx);
2857
+ const after = stats.declAdded[idx];
2858
+ violations.push({
2859
+ file,
2860
+ line: after.line ?? before.line,
2861
+ type: "TEST_DEREGISTERED",
2862
+ reason:
2863
+ `Test Tamper Guard: ${JSON.stringify(before.name)} was renamed to ${JSON.stringify(after.name)} in ${file}` +
2864
+ `${after.line ? `:${after.line}` : ""}. The runner collects tests by name, so the test still exists in ` +
2865
+ `the file and no longer runs — the same effect as deleting it, with none of the signs. ` +
2866
+ `If the test is genuinely obsolete, delete it; if it is being turned into a helper, say so with ` +
2867
+ `--allow-test-change deregistration.`,
2868
+ });
2869
+ }
2870
+ }
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
+
2566
3093
  for (const [file, stats] of fileAssertions.entries()) {
2567
3094
  // An expectation that was rewritten rather than removed.
2568
3095
  //
@@ -2668,10 +3195,33 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2668
3195
  // assertion, is not the same as "checked and clean" — it is the state where
2669
3196
  // this guard has nothing to say. Saying nothing and saying "approved" have
2670
3197
  // to look different, which is the whole reason `status` exists.
3198
+ // `unreadable` is the evidence, and it is required.
3199
+ //
3200
+ // `|| examined > 0` used to stand here, and it threw away the distinction
3201
+ // this whole apparatus exists to draw. `ASSERTION_SHAPED` and `unreadable[]`
3202
+ // were built to separate "assertion-shaped lines were present and none of
3203
+ // them parsed" — a dialect the guard cannot read — from "there were no
3204
+ // assertions in these lines at all", which is most ordinary work on a test
3205
+ // file. That clause collapsed the two, so *any* changed substantive line in
3206
+ // a test file with no recognised assertion became a CRITICAL block:
3207
+ // measured on `pytest-dev/iniconfig`, renaming a test function did it, and
3208
+ // so did adding `import os`.
3209
+ //
3210
+ // The tell was in the finding itself: it carried `file: null`, `line: null`
3211
+ // and no sample, because `unreadable` was empty — the guard blocked while
3212
+ // holding no evidence of anything, and advised a pytest repository that its
3213
+ // assertion library might be unsupported, from a list that names pytest.
3214
+ //
3215
+ // Nothing is weakened by requiring the evidence. A removed or rewritten
3216
+ // assertion is a recognised assertion line, so it raises `assertionsSeen`
3217
+ // and goes to the ordinary removal and weakening checks; it never reached
3218
+ // this branch. What is lost is only the blanket, and a blanket that fires
3219
+ // on `import os` teaches its way around itself: the remedy it printed was
3220
+ // `tamperGuard: "warn"`, which switches the real guard off too.
2671
3221
  const status =
2672
3222
  reported.length > 0
2673
3223
  ? "FAIL"
2674
- : assertionsSeen === 0 && (unreadable.length > 0 || examined > 0)
3224
+ : assertionsSeen === 0 && unreadable.length > 0
2675
3225
  ? "UNREADABLE"
2676
3226
  : examined > 0
2677
3227
  ? "PASS"