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/.agent/rules/jules-protocol.md +0 -1
- package/CHANGELOG.md +30 -0
- package/JULES_RULES_TEMPLATE.md +0 -1
- package/README.md +56 -1
- package/ROADMAP_V1.md +22 -6
- package/bin/agentctl.mjs +57 -18
- package/package.json +1 -1
- package/scripts/guard-reach-check.mjs +28 -5
- package/src/config.mjs +346 -2
- package/src/coverage.mjs +85 -13
- package/src/engine.mjs +97 -142
- package/src/git.mjs +104 -1
- package/src/guard-policy.mjs +413 -6
- package/src/ops/command-registry.mjs +109 -0
- package/src/ops/test-collection.mjs +29 -3
- package/src/security.mjs +458 -19
- package/src/stack-detector.mjs +21 -0
- package/src/test-paths.mjs +18 -1
- package/src/wizard-init.mjs +16 -12
- package/src/wizard-oracle.mjs +1 -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.
|
|
@@ -1907,17 +1984,26 @@ function declaredTestName(text) {
|
|
|
1907
1984
|
}
|
|
1908
1985
|
|
|
1909
1986
|
/**
|
|
1910
|
-
*
|
|
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
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
2623
|
-
// `def
|
|
2624
|
-
// the file, fully written, with all its assertions intact. Every count in
|
|
2625
|
-
// this guard stays level: nothing
|
|
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
|
-
//
|
|
2628
|
-
//
|
|
2629
|
-
//
|
|
2630
|
-
//
|
|
2631
|
-
//
|
|
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
|
//
|
package/src/stack-detector.mjs
CHANGED
|
@@ -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(/&&|;/)
|
package/src/test-paths.mjs
CHANGED
|
@@ -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`,
|
|
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)$/;
|