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/.agent/rules/jules-protocol.md +0 -1
- package/CHANGELOG.md +66 -0
- package/JULES_RULES_TEMPLATE.md +0 -1
- package/README.md +56 -1
- package/ROADMAP_V1.md +31 -6
- package/bin/agentctl.mjs +101 -8
- package/package.json +1 -1
- package/scripts/guard-reach-check.mjs +74 -8
- package/src/config.mjs +434 -5
- package/src/coverage.mjs +85 -13
- package/src/engine.mjs +106 -130
- package/src/git.mjs +104 -1
- package/src/guard-policy.mjs +581 -0
- package/src/ops/command-registry.mjs +109 -0
- package/src/ops/test-collection.mjs +103 -3
- package/src/security.mjs +554 -4
- package/src/stack-detector.mjs +73 -14
- package/src/test-paths.mjs +18 -1
- package/src/wizard-init.mjs +54 -27
- package/src/wizard-oracle.mjs +1 -1
- package/src/wizard-task.mjs +15 -1
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
3224
|
+
: assertionsSeen === 0 && unreadable.length > 0
|
|
2675
3225
|
? "UNREADABLE"
|
|
2676
3226
|
: examined > 0
|
|
2677
3227
|
? "PASS"
|