agent-roast 0.14.0 → 0.16.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/README.md +6 -6
- package/dist/{chunk-MZBP537U.js → chunk-52FUQ4EP.js} +616 -63
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,11 +79,11 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
|
|
|
79
79
|
| Rule | Fires when | By default |
|
|
80
80
|
| --- | --- | :-: |
|
|
81
81
|
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x` | blocks |
|
|
82
|
-
| `guard-weakened` | a coverage threshold is lowered, `strict` is switched off, a lint rule is downgraded, a CI step may fail, a workflow stops running tests | blocks |
|
|
83
|
-
| `test-skip` | a test is switched off: `it.skip`, `@pytest.mark.skip`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... | blocks |
|
|
84
|
-
| `test-focus`, `trivial-assert` | `.only` / `fit`; `expect(true).toBe(true)` and its relatives in ten languages | blocks |
|
|
82
|
+
| `guard-weakened` | a coverage threshold is lowered, `strict` is switched off, a lint rule is downgraded, a CI step may fail, a workflow stops running tests, a `conftest.py` hook sets a report to `passed` | blocks |
|
|
83
|
+
| `test-skip` | a test is switched off or expected to fail: `it.skip`, `@pytest.mark.skip`, `@pytest.mark.xfail`, `it.fails`, `test.fixme`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... | blocks |
|
|
84
|
+
| `test-focus`, `trivial-assert` | `.only` / `fit`; `expect(true).toBe(true)` and its relatives in ten languages; a test that asserts on a function it mocked itself | blocks |
|
|
85
85
|
| `assertion-removed`, `test-removed`, `expectation-rewritten` | a test loses assertions, disappears, or its expected value is edited next to production code | warns |
|
|
86
|
-
| `swallowed-error`, `type-escape`, `stub-abandonment` | `catch {}` / `except: pass`, `as any` / `# type: ignore`, `throw new Error("Not implemented")` | warns |
|
|
86
|
+
| `swallowed-error`, `type-escape`, `stub-abandonment` | `catch {}` / `except: pass`, `as any` / `# type: ignore` / `# pragma: no cover` / `istanbul ignore`, `throw new Error("Not implemented")` | warns |
|
|
87
87
|
|
|
88
88
|
Renames, moved tests, parametrization, moved assertions and reformatting are recognized and not reported, and neither is a skip
|
|
89
89
|
that depends on the environment (`if testing.Short() { t.Skip() }`, `test.skip(isMobile, ...)`). JavaScript/TypeScript, Python
|
|
@@ -111,9 +111,9 @@ old debt with a baseline: [docs/reference.md](docs/reference.md#configuration).
|
|
|
111
111
|
Measured, with the denominators and the caveats in [docs/benchmarks.md](docs/benchmarks.md). **Weakened checks are rare, mostly
|
|
112
112
|
deliberate, and not specific to AI. agent-roast is a tripwire that stays silent until a check gets weaker.**
|
|
113
113
|
|
|
114
|
-
- **
|
|
114
|
+
- **119 of 119** hand-labeled cheating edits are caught and **84 of 84** harmless look-alikes (reformats, moves, raised
|
|
115
115
|
thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
|
|
116
|
-
they do not prove accuracy.
|
|
116
|
+
they do not prove accuracy. One gap is documented and still open.
|
|
117
117
|
- **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
|
|
118
118
|
(Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
|
|
119
119
|
AI-signed commit were 0.81 times those of another commit (95% CI 0.40–1.67). There is no sign that AI-signed commits weaken
|
|
@@ -757,22 +757,22 @@ var RULE_GUIDES = {
|
|
|
757
757
|
example: 'test.only("auth", ...) \u2192 test("auth", ...), then run the full suite'
|
|
758
758
|
},
|
|
759
759
|
"test-skip": {
|
|
760
|
-
title: "Skipped or
|
|
761
|
-
why: "The test runner may report success without executing this behavior check.",
|
|
762
|
-
suggestion: "Re-enable the test and fix its failure. If a skip is intentional, document its reason and track when to restore it.",
|
|
763
|
-
example: 'it.skip("auth", ...) \u2192
|
|
760
|
+
title: "Skipped, disabled or expected-to-fail test",
|
|
761
|
+
why: "The test runner may report success without executing this behavior check, or without letting it fail.",
|
|
762
|
+
suggestion: "Re-enable the test and fix its failure. If a skip or an expected failure is intentional, document its reason and track when to restore it.",
|
|
763
|
+
example: 'it.skip("auth", ...) or @pytest.mark.xfail \u2192 remove the marker after fixing the failing behavior'
|
|
764
764
|
},
|
|
765
765
|
"trivial-assert": {
|
|
766
766
|
title: "Assertion that always passes",
|
|
767
|
-
why: "Comparing a constant to itself does not check the implementation and cannot catch regressions.",
|
|
768
|
-
suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants.",
|
|
769
|
-
example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true)"
|
|
767
|
+
why: "Comparing a constant to itself, or checking the output of a function the test itself replaces with a mock, does not check the implementation and cannot catch regressions.",
|
|
768
|
+
suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants. Mock the dependencies of the code under test, never the code under test.",
|
|
769
|
+
example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true); jest.mock('./price', ...) then expect(price(cart)).toBe(42) \u2192 mock the price list and assert total(cart)"
|
|
770
770
|
},
|
|
771
771
|
"type-escape": {
|
|
772
|
-
title: "Type or
|
|
773
|
-
why: "A suppression can hide incompatible values
|
|
774
|
-
suggestion: "Narrow or validate the value at the boundary. Keep a suppression only with a specific reason and the smallest possible scope.",
|
|
775
|
-
example: "response as any \u2192 parse and validate response before using it"
|
|
772
|
+
title: "Type, lint or coverage check bypass",
|
|
773
|
+
why: "A suppression can hide incompatible values, a disabled correctness check, or code that no test reaches. Some suppressions are justified.",
|
|
774
|
+
suggestion: "Narrow or validate the value at the boundary, or test the excluded code. Keep a suppression only with a specific reason and the smallest possible scope.",
|
|
775
|
+
example: "response as any \u2192 parse and validate response before using it; # pragma: no cover \u2192 a test for that branch"
|
|
776
776
|
},
|
|
777
777
|
"swallowed-error": {
|
|
778
778
|
title: "Error discarded without handling",
|
|
@@ -812,9 +812,9 @@ var RULE_GUIDES = {
|
|
|
812
812
|
},
|
|
813
813
|
"guard-weakened": {
|
|
814
814
|
title: "Quality gate made easier to pass",
|
|
815
|
-
why: "Lower coverage thresholds, disabled compiler strictness, downgraded lint rules, tolerated CI failures
|
|
815
|
+
why: "Lower coverage thresholds, disabled compiler strictness, downgraded lint rules, tolerated CI failures, an always-passing test script or a test-harness hook that rewrites the result let defects through without touching the code.",
|
|
816
816
|
suggestion: "Keep the gate and fix the underlying failure. If the change is deliberate, record the reason and a plan to restore it.",
|
|
817
|
-
example: 'coverageThreshold lines: 80 \u2192 60, "strict": true \u2192 false, continue-on-error: true'
|
|
817
|
+
example: 'coverageThreshold lines: 80 \u2192 60, "strict": true \u2192 false, continue-on-error: true, report.outcome = "passed"'
|
|
818
818
|
},
|
|
819
819
|
"panic-loop": {
|
|
820
820
|
title: "Rapid overlapping fix commits",
|
|
@@ -1163,8 +1163,19 @@ var PYTHON_TEST_SKIP_PATTERNS = [
|
|
|
1163
1163
|
{ regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
1164
1164
|
{ regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)", imperative: true },
|
|
1165
1165
|
{ regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)", imperative: false },
|
|
1166
|
-
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true }
|
|
1166
|
+
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true },
|
|
1167
|
+
// A test that is expected to fail cannot fail the run, which is what a skip does too.
|
|
1168
|
+
{ regex: /(?<![\w.])pytest\s*\.\s*xfail\s*\(/, name: "imperative pytest xfail (pytest.xfail)", imperative: true },
|
|
1169
|
+
{ regex: /@(?:unittest\s*\.\s*)?expectedFailure\b/, name: "test expected to fail (@unittest.expectedFailure)", imperative: false }
|
|
1167
1170
|
];
|
|
1171
|
+
var PYTHON_MARKER = /(@\s*|(?<![\w.])marks\s*=\s*\[?\s*|^[ \t]*pytestmark\s*=\s*\[?\s*)pytest\s*\.\s*mark\s*\.\s*(skip|xfail)\b/gm;
|
|
1172
|
+
function isConditionalXfail(args) {
|
|
1173
|
+
if (!args?.length) return false;
|
|
1174
|
+
const keyword = (arg) => /^(\w+)\s*=(?!=)/.exec(arg)?.[1];
|
|
1175
|
+
const condition = keyword(args[0]) ? args.find((arg) => keyword(arg) === "condition")?.replace(/^condition\s*=\s*/, "") : args[0];
|
|
1176
|
+
if (condition === void 0 || /^(?:True|1)$/.test(condition.trim())) return false;
|
|
1177
|
+
return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(condition);
|
|
1178
|
+
}
|
|
1168
1179
|
function isGuardedSkip(lines) {
|
|
1169
1180
|
const indentOf = (text) => text.length - text.trimStart().length;
|
|
1170
1181
|
const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
|
|
@@ -1305,44 +1316,67 @@ function isGuardedBlockSkip(lines, profile) {
|
|
|
1305
1316
|
}
|
|
1306
1317
|
return compiledConditionally(i + 1);
|
|
1307
1318
|
}
|
|
1308
|
-
var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
|
|
1319
|
+
var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only|fixme|failing|fails|fail)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
|
|
1309
1320
|
function isConditionalSkip(source, raw, open2) {
|
|
1310
|
-
|
|
1321
|
+
const args = callArguments(source, raw, open2);
|
|
1322
|
+
if (!args) return false;
|
|
1323
|
+
const isString = (arg) => /^(['"`])[\s\S]*\1$/.test(arg);
|
|
1324
|
+
if (!args.length || isString(args[0]) || args[0] === "true") return false;
|
|
1325
|
+
if (args.length > 1 && !isString(args.at(-1))) return false;
|
|
1326
|
+
return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(args[0]);
|
|
1327
|
+
}
|
|
1328
|
+
function callArguments(source, raw, open2) {
|
|
1329
|
+
if (source[open2] !== "(") return null;
|
|
1311
1330
|
const args = [];
|
|
1312
1331
|
let depth = 0;
|
|
1313
1332
|
let start = open2 + 1;
|
|
1314
1333
|
for (let i = start; i < source.length && i - open2 <= 2e3; i++) {
|
|
1315
1334
|
const char = source[i];
|
|
1316
|
-
if (char === "\0") return
|
|
1335
|
+
if (char === "\0") return null;
|
|
1317
1336
|
if ("([{".includes(char)) depth++;
|
|
1318
1337
|
else if (")]}".includes(char)) {
|
|
1319
1338
|
if (depth) {
|
|
1320
1339
|
depth--;
|
|
1321
1340
|
continue;
|
|
1322
1341
|
}
|
|
1323
|
-
if (char !== ")") return
|
|
1342
|
+
if (char !== ")") return null;
|
|
1324
1343
|
args.push(raw.slice(start, i).trim());
|
|
1325
1344
|
while (args.length && !args.at(-1)) args.pop();
|
|
1326
|
-
|
|
1327
|
-
if (!args.length || isString(args[0]) || args[0] === "true") return false;
|
|
1328
|
-
if (args.length > 1 && !isString(args.at(-1))) return false;
|
|
1329
|
-
return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(args[0]);
|
|
1345
|
+
return args;
|
|
1330
1346
|
} else if (char === "," && !depth) {
|
|
1331
1347
|
args.push(raw.slice(start, i).trim());
|
|
1332
1348
|
start = i + 1;
|
|
1333
1349
|
}
|
|
1334
1350
|
}
|
|
1335
|
-
return
|
|
1351
|
+
return null;
|
|
1336
1352
|
}
|
|
1337
1353
|
var JS_COMMENT_DIRECTIVE_PATTERNS = [
|
|
1338
1354
|
{ regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
|
|
1339
1355
|
{ regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
|
|
1340
1356
|
{ regex: /^\s*\/\/\s*@ts-nocheck\b/, name: "compiler check disable (@ts-nocheck)" },
|
|
1341
|
-
{ regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" }
|
|
1357
|
+
{ regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" },
|
|
1358
|
+
{ regex: /^\s*\/\/\s*biome-ignore\b/, name: "linter suppression (biome-ignore)" },
|
|
1359
|
+
{ regex: /^\s*\/\/\s*(?:istanbul|c8|v8|node:coverage)\s+(?:ignore\b(?!\s+(?:stop|end)\b)|disable\b)/, name: "coverage exclusion (istanbul / c8 / v8 ignore)" }
|
|
1360
|
+
];
|
|
1361
|
+
var JS_BLOCK_DIRECTIVE_PATTERNS = [
|
|
1362
|
+
{ regex: /^\/\*\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" },
|
|
1363
|
+
{ regex: /^\/\*\s*(?:istanbul|c8|v8|node:coverage)\s+(?:ignore\b(?!\s+(?:stop|end)\b)|disable\b)/, name: "coverage exclusion (istanbul / c8 / v8 ignore)" }
|
|
1342
1364
|
];
|
|
1365
|
+
var COVERAGE_IDIOM = /^\s*(?:if\s+(?:(?:typing|t)\s*\.\s*)?TYPE_CHECKING\b|if\s+__name__\s*==|raise\s+(?:NotImplementedError|AssertionError)\b|(?:\w+\s*\.\s*)?assert_never\s*\(|@(?:abc\s*\.\s*)?abstractmethod\b|except\s*\(?\s*(?:ImportError|ModuleNotFoundError)\b|import\s|from\s+\S+\s+import\b|\.\.\.\s*$|pass\s*$|else\s*:|case\s+_\s*:|(?:el)?if\s+sys\s*\.\s*(?:version_info|platform)\b)/;
|
|
1343
1366
|
var PYTHON_COMMENT_DIRECTIVE_PATTERNS = [
|
|
1344
1367
|
{ regex: /#\s*type:\s*ignore\b/, name: "python type ignore" },
|
|
1345
|
-
{ regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" }
|
|
1368
|
+
{ regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" },
|
|
1369
|
+
{ regex: /#\s*pragma:\s*no\s*(?:cover|branch)\b/i, name: "coverage exclusion (# pragma: no cover)", unless: COVERAGE_IDIOM },
|
|
1370
|
+
{ regex: /#\s*pylint:\s*(?:disable|skip-file)\b/, name: "python linter suppression (# pylint: disable)" },
|
|
1371
|
+
{ regex: /#\s*pyright:\s*ignore\b/, name: "python type ignore (# pyright: ignore)" },
|
|
1372
|
+
{ regex: /#\s*mypy:\s*ignore-errors\b/, name: "python type check disabled for the file (# mypy: ignore-errors)" },
|
|
1373
|
+
{ regex: /#\s*(?:flake8|ruff):\s*noqa\b/i, name: "python linter suppression for the file (# flake8: noqa)" }
|
|
1374
|
+
];
|
|
1375
|
+
var PYTHON_HARNESS_FILE = /(?:^|\/)(?:conftest|sitecustomize|usercustomize)\.py$/i;
|
|
1376
|
+
var PYTHON_HARNESS_PATTERNS = [
|
|
1377
|
+
{ regex: /\.\s*outcome\s*=\s*(["'])passed\1/, name: "test report outcome forced to passed" },
|
|
1378
|
+
{ regex: /(?<![\w.])call\s*\.\s*excinfo\s*=\s*None\b/, name: "failure cleared from a test report hook (call.excinfo = None)" },
|
|
1379
|
+
{ regex: /(?<![\w.])session\s*\.\s*exitstatus\s*=\s*(?:0\b|(?:pytest\s*\.\s*)?ExitCode\s*\.\s*OK\b)/, name: "pytest exit status forced to 0" }
|
|
1346
1380
|
];
|
|
1347
1381
|
var CODE_TYPE_ESCAPE_PATTERNS = [
|
|
1348
1382
|
{ regex: /\bas\s+any\b/, name: "type bypass (as any)" },
|
|
@@ -1419,13 +1453,13 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1419
1453
|
hasSupportedChanges = true;
|
|
1420
1454
|
filesInspected++;
|
|
1421
1455
|
linesAdded += file.addedLines.length;
|
|
1422
|
-
const
|
|
1456
|
+
const javascript2 = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
|
|
1423
1457
|
const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
|
|
1424
|
-
const
|
|
1458
|
+
const python2 = /\.py$/i.test(file.path);
|
|
1425
1459
|
const rust = /\.rs$/i.test(file.path);
|
|
1426
|
-
const braceCatches =
|
|
1427
|
-
const directives =
|
|
1428
|
-
const stubs =
|
|
1460
|
+
const braceCatches = javascript2 || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
|
|
1461
|
+
const directives = javascript2 ? JS_COMMENT_DIRECTIVE_PATTERNS : python2 ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1462
|
+
const stubs = javascript2 ? JS_STUB_PATTERNS : python2 ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1429
1463
|
const firstFinding = infractions.length;
|
|
1430
1464
|
const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
|
|
1431
1465
|
const masker = new SourceMasker(file.path);
|
|
@@ -1438,7 +1472,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1438
1472
|
for (const start of masked.templateStarts) testCode[start] = "";
|
|
1439
1473
|
maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
|
|
1440
1474
|
const isComment = !cleanedLine.trim();
|
|
1441
|
-
if (
|
|
1475
|
+
if (python2 && !isComment) {
|
|
1442
1476
|
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
1443
1477
|
if (rule.regex.test(cleanedLine)) {
|
|
1444
1478
|
if (rule.imperative && isGuardedSkip(maskedLines)) break;
|
|
@@ -1465,7 +1499,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1465
1499
|
}
|
|
1466
1500
|
let foundEscape = false;
|
|
1467
1501
|
for (const rule of directives) {
|
|
1468
|
-
if (rule.regex.test(masked.comments)) {
|
|
1502
|
+
if (rule.regex.test(masked.comments) && !rule.unless?.test(cleanedLine)) {
|
|
1469
1503
|
infractions.push({
|
|
1470
1504
|
type: "type-escape",
|
|
1471
1505
|
file: file.path,
|
|
@@ -1479,11 +1513,12 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1479
1513
|
break;
|
|
1480
1514
|
}
|
|
1481
1515
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1516
|
+
const blockDirective = javascript2 && !foundEscape ? JS_BLOCK_DIRECTIVE_PATTERNS.find((rule) => masked.blockStarts.some((start) => rule.regex.test(rawLine.slice(start)))) : void 0;
|
|
1517
|
+
if (blockDirective) {
|
|
1518
|
+
infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: blockDirective.name });
|
|
1484
1519
|
foundEscape = true;
|
|
1485
1520
|
}
|
|
1486
|
-
if (
|
|
1521
|
+
if (python2 && !isComment && isTestFile(file.path)) {
|
|
1487
1522
|
for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
|
|
1488
1523
|
if (rule.regex.test(cleanedLine)) {
|
|
1489
1524
|
infractions.push({
|
|
@@ -1543,6 +1578,34 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1543
1578
|
infractions.push({ type: "stub-abandonment", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
|
|
1544
1579
|
}
|
|
1545
1580
|
}
|
|
1581
|
+
if (python2) {
|
|
1582
|
+
const reportedSkips = new Set(infractions.slice(firstFinding).filter((f) => f.type === "test-skip").map((f) => f.lineNumber));
|
|
1583
|
+
for (const match of source.matchAll(PYTHON_MARKER)) {
|
|
1584
|
+
const xfail = match[2] === "xfail";
|
|
1585
|
+
let open2 = match.index + match[0].length;
|
|
1586
|
+
while (source[open2] === " " || source[open2] === " ") open2++;
|
|
1587
|
+
if (xfail && isConditionalXfail(callArguments(source, raw, open2))) continue;
|
|
1588
|
+
const location = evidence(match.index, match[0].length);
|
|
1589
|
+
if (reportedSkips.has(location.lineNumber)) continue;
|
|
1590
|
+
reportedSkips.add(location.lineNumber);
|
|
1591
|
+
infractions.push({
|
|
1592
|
+
type: "test-skip",
|
|
1593
|
+
file: file.path,
|
|
1594
|
+
...location,
|
|
1595
|
+
commitHash,
|
|
1596
|
+
commitDate,
|
|
1597
|
+
reason: xfail ? "test marked as expected to fail (pytest.mark.xfail)" : "test skipped by a marker (pytest.mark.skip)"
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
if (isTestFile(file.path) || PYTHON_HARNESS_FILE.test(file.path)) {
|
|
1601
|
+
for (const rule of PYTHON_HARNESS_PATTERNS) {
|
|
1602
|
+
for (const match of raw.matchAll(new RegExp(rule.regex.source, "g"))) {
|
|
1603
|
+
if (code[match.index] !== raw[match.index]) continue;
|
|
1604
|
+
infractions.push({ type: "guard-weakened", file: file.path, ...evidence(match.index, match[0].length), commitHash, commitDate, reason: rule.name });
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1546
1609
|
if (typescript) {
|
|
1547
1610
|
const reported = new Set(infractions.slice(firstFinding).filter((f) => f.type === "type-escape").map((f) => f.lineNumber));
|
|
1548
1611
|
for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
|
|
@@ -1554,7 +1617,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1554
1617
|
}
|
|
1555
1618
|
}
|
|
1556
1619
|
}
|
|
1557
|
-
if (
|
|
1620
|
+
if (javascript2) {
|
|
1558
1621
|
for (const match of source.matchAll(JS_TEST_API)) {
|
|
1559
1622
|
let previous = match.index - 1;
|
|
1560
1623
|
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
@@ -1562,15 +1625,16 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1562
1625
|
if (match[2] && !isTestFile(file.path)) continue;
|
|
1563
1626
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1564
1627
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1565
|
-
if (match[1] === "skip" && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
|
|
1628
|
+
if ((match[1] === "skip" || match[1] === "fixme" || match[1] === "fail") && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
|
|
1566
1629
|
const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
|
|
1630
|
+
const expectedToFail = match[1] === "failing" || match[1] === "fails" || match[1] === "fail";
|
|
1567
1631
|
infractions.push({
|
|
1568
1632
|
type: focus ? "test-focus" : "test-skip",
|
|
1569
1633
|
file: file.path,
|
|
1570
1634
|
...evidence(match.index, match[0].length),
|
|
1571
1635
|
commitHash,
|
|
1572
1636
|
commitDate,
|
|
1573
|
-
reason: focus ? "focused test or suite excludes other tests from the run" : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
|
|
1637
|
+
reason: focus ? "focused test or suite excludes other tests from the run" : expectedToFail ? `test expected to fail, so its failure cannot fail the run (${match[0].replace(/\s/g, "")})` : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
|
|
1574
1638
|
});
|
|
1575
1639
|
}
|
|
1576
1640
|
if (isTestFile(file.path)) {
|
|
@@ -1595,9 +1659,9 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1595
1659
|
}
|
|
1596
1660
|
for (let index = 0; index < maskedLines.length; index++) {
|
|
1597
1661
|
const start = maskedLines[index];
|
|
1598
|
-
const inlineExcept =
|
|
1662
|
+
const inlineExcept = python2 && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
|
|
1599
1663
|
if (inlineExcept) infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "silent python error swallow (except: pass)" });
|
|
1600
|
-
const isExcept =
|
|
1664
|
+
const isExcept = python2 && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
|
|
1601
1665
|
if (!isExcept) continue;
|
|
1602
1666
|
let next = index + 1;
|
|
1603
1667
|
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
@@ -3100,6 +3164,351 @@ import path6 from "path";
|
|
|
3100
3164
|
|
|
3101
3165
|
// src/tampering.ts
|
|
3102
3166
|
import { createHash as createHash3 } from "crypto";
|
|
3167
|
+
|
|
3168
|
+
// src/mocks.ts
|
|
3169
|
+
function views(text, language) {
|
|
3170
|
+
const masker = new SourceMasker(language === "js" ? "file.ts" : "file.py");
|
|
3171
|
+
const lines = text.split(/\r?\n/);
|
|
3172
|
+
const code = lines.map((line, index) => masker.mask(line, index + 1).code).join("\n");
|
|
3173
|
+
const starts = [];
|
|
3174
|
+
let offset = 0;
|
|
3175
|
+
for (const line of lines) {
|
|
3176
|
+
starts.push(offset);
|
|
3177
|
+
offset += line.length + 1;
|
|
3178
|
+
}
|
|
3179
|
+
const lineOf2 = (index) => {
|
|
3180
|
+
let low = 0, high = starts.length - 1;
|
|
3181
|
+
while (low < high) {
|
|
3182
|
+
const middle = low + high + 1 >>> 1;
|
|
3183
|
+
if (starts[middle] <= index) low = middle;
|
|
3184
|
+
else high = middle - 1;
|
|
3185
|
+
}
|
|
3186
|
+
return low + 1;
|
|
3187
|
+
};
|
|
3188
|
+
return { raw: lines.join("\n"), code, lines, starts, lineOf: lineOf2 };
|
|
3189
|
+
}
|
|
3190
|
+
function closing(code, open2) {
|
|
3191
|
+
let depth = 0;
|
|
3192
|
+
for (let i = open2; i < code.length && i - open2 < 2e4; i++) {
|
|
3193
|
+
if (code[i] === "(") depth++;
|
|
3194
|
+
else if (code[i] === ")" && --depth === 0) return i;
|
|
3195
|
+
}
|
|
3196
|
+
return -1;
|
|
3197
|
+
}
|
|
3198
|
+
var STRING_AFTER = /^\s*(['"`])([^'"`\n]+)\1/;
|
|
3199
|
+
var within = (range, index) => !range || index >= range[0] && index <= range[1];
|
|
3200
|
+
var innermost = (ranges, index) => ranges.filter((range) => index >= range[0] && index <= range[1]).sort((a, b) => a[1] - a[0] - (b[1] - b[0]))[0] ?? null;
|
|
3201
|
+
function javascript(text) {
|
|
3202
|
+
const { raw, code, lines, lineOf: lineOf2 } = views(text, "js");
|
|
3203
|
+
const callbacks = [];
|
|
3204
|
+
for (const match of code.matchAll(/(?<![\w$.])(?:it|test|beforeEach|beforeAll|afterEach|afterAll)(?:\s*\.\s*\w+)*\s*\(/g)) {
|
|
3205
|
+
const open2 = match.index + match[0].length - 1;
|
|
3206
|
+
const end = closing(code, open2);
|
|
3207
|
+
if (end > 0) callbacks.push([open2, end]);
|
|
3208
|
+
}
|
|
3209
|
+
const programLevel = (index) => {
|
|
3210
|
+
let depth = 0;
|
|
3211
|
+
for (let i = 0; i < index; i++) {
|
|
3212
|
+
const char = code[i];
|
|
3213
|
+
if (char === "(" || char === "{" || char === "[") depth++;
|
|
3214
|
+
else if (char === ")" || char === "}" || char === "]") depth--;
|
|
3215
|
+
}
|
|
3216
|
+
return depth === 0;
|
|
3217
|
+
};
|
|
3218
|
+
const mocked = /* @__PURE__ */ new Map();
|
|
3219
|
+
for (const match of code.matchAll(/(?<![\w$.])(?:jest|vi)\s*\.\s*mock\s*\(/g)) {
|
|
3220
|
+
if (!programLevel(match.index)) continue;
|
|
3221
|
+
const open2 = match.index + match[0].length - 1;
|
|
3222
|
+
const specifier = STRING_AFTER.exec(raw.slice(open2 + 1))?.[2];
|
|
3223
|
+
if (!specifier) continue;
|
|
3224
|
+
const end = closing(code, open2);
|
|
3225
|
+
const args = end > 0 ? raw.slice(open2, end) : "";
|
|
3226
|
+
if (/requireActual|importActual|importOriginal/.test(args)) continue;
|
|
3227
|
+
const factory = /^\(\s*(['"`])[^'"`\n]+\1\s*,/.test(args) && !/,\s*\{\s*spy\s*:\s*true\b/.test(args);
|
|
3228
|
+
if (!mocked.has(specifier)) mocked.set(specifier, { index: match.index, fake: factory });
|
|
3229
|
+
}
|
|
3230
|
+
if (!mocked.size) return [];
|
|
3231
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
3232
|
+
const bind = (clause, specifier) => {
|
|
3233
|
+
const named = /\{([^}]*)\}/.exec(clause)?.[1];
|
|
3234
|
+
for (const part of (named ?? "").split(",")) {
|
|
3235
|
+
const item = part.trim().replace(/^type\s+/, "");
|
|
3236
|
+
if (!item) continue;
|
|
3237
|
+
const [imported, local] = item.includes(" as ") ? item.split(/\s+as\s+/) : item.includes(":") ? item.split(/\s*:\s*/) : [item, item];
|
|
3238
|
+
if (/^[\w$]+$/.test(imported) && /^[\w$]+$/.test(local)) bindings.set(local, { specifier, name: imported, namespace: false });
|
|
3239
|
+
}
|
|
3240
|
+
const namespace = /\*\s*as\s+([\w$]+)/.exec(clause)?.[1];
|
|
3241
|
+
if (namespace) bindings.set(namespace, { specifier, name: "*", namespace: true });
|
|
3242
|
+
const standalone = /^\s*([\w$]+)\s*(?:,|$)/.exec(clause.replace(/\{[^}]*\}/, ""))?.[1];
|
|
3243
|
+
if (standalone && standalone !== "type" && !/^\s*\{/.test(clause)) bindings.set(standalone, { specifier, name: "default", namespace: false });
|
|
3244
|
+
};
|
|
3245
|
+
for (const match of code.matchAll(/\bimport\s+([^;'"`]*?)\bfrom\b/g)) {
|
|
3246
|
+
const specifier = STRING_AFTER.exec(raw.slice(match.index + match[0].length))?.[2];
|
|
3247
|
+
if (specifier) bind(match[1], specifier);
|
|
3248
|
+
}
|
|
3249
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+(\{[^}]*\}|[\w$]+)\s*=\s*(?:await\s+)?require\s*\(/g)) {
|
|
3250
|
+
const specifier = STRING_AFTER.exec(raw.slice(match.index + match[0].length))?.[2];
|
|
3251
|
+
if (!specifier) continue;
|
|
3252
|
+
if (match[1].startsWith("{")) bind(match[1], specifier);
|
|
3253
|
+
else bindings.set(match[1], { specifier, name: "*", namespace: true });
|
|
3254
|
+
}
|
|
3255
|
+
const found = /* @__PURE__ */ new Map();
|
|
3256
|
+
for (const match of code.matchAll(/(?<![\w$.])expect\s*\(/g)) {
|
|
3257
|
+
const open2 = match.index + match[0].length - 1;
|
|
3258
|
+
const end = closing(code, open2);
|
|
3259
|
+
if (end < 0) continue;
|
|
3260
|
+
const checked = code.slice(open2 + 1, end);
|
|
3261
|
+
let call = /^\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*(?:\.\s*([A-Za-z_$][\w$]*)\s*)?\(/.exec(checked);
|
|
3262
|
+
const variable = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(checked)?.[1];
|
|
3263
|
+
if (!call && variable) {
|
|
3264
|
+
const from = innermost(callbacks, match.index)?.[0] ?? 0;
|
|
3265
|
+
const name2 = variable.replace(/\$/g, "\\$");
|
|
3266
|
+
const assigned = [...code.slice(from, match.index).matchAll(new RegExp(`(?<![\\w$.])${name2}\\s*=(?!=)\\s*(?:await\\s+)?([A-Za-z_$][\\w$]*)\\s*(?:\\.\\s*([A-Za-z_$][\\w$]*)\\s*)?\\(`, "g"))].at(-1);
|
|
3267
|
+
if (assigned) call = assigned;
|
|
3268
|
+
}
|
|
3269
|
+
if (!call) continue;
|
|
3270
|
+
const binding = bindings.get(call[1]);
|
|
3271
|
+
const mock = binding && mocked.get(binding.specifier);
|
|
3272
|
+
if (!binding || !mock) continue;
|
|
3273
|
+
const name = binding.namespace ? call[2] : call[2] ? void 0 : binding.name;
|
|
3274
|
+
if (!name) continue;
|
|
3275
|
+
const identifier = call[1].replace(/\$/g, "\\$");
|
|
3276
|
+
const configured = new RegExp(`(?<![\\w$.])${identifier}\\b[^;]{0,80}?\\.\\s*mock(?:Return|Resolved|Rejected|Implementation)\\w*\\s*\\(`).test(raw);
|
|
3277
|
+
if (!mock.fake && !configured) continue;
|
|
3278
|
+
const key = `${binding.specifier}::${name}`;
|
|
3279
|
+
if (found.has(key)) continue;
|
|
3280
|
+
const line = lineOf2(mock.index);
|
|
3281
|
+
found.set(key, {
|
|
3282
|
+
key,
|
|
3283
|
+
line,
|
|
3284
|
+
snippet: lines[line - 1].trim().slice(0, 120),
|
|
3285
|
+
reason: `assertion on the output of ${name}(), which this test replaces with a mock of "${binding.specifier}"`
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
return [...found.values()];
|
|
3289
|
+
}
|
|
3290
|
+
var DOTTED = /^[A-Za-z_][\w.]*$/;
|
|
3291
|
+
function python(text) {
|
|
3292
|
+
const { raw, code, lines, starts, lineOf: lineOf2 } = views(text, "py");
|
|
3293
|
+
const alias = /* @__PURE__ */ new Map();
|
|
3294
|
+
for (const match of code.matchAll(/^[ \t]*import\s+([\w.]+)\s+as\s+(\w+)/gm)) alias.set(match[2], match[1]);
|
|
3295
|
+
for (const match of code.matchAll(/^[ \t]*from\s+([\w.]+)\s+import\s+([^\n(]+)/gm)) {
|
|
3296
|
+
for (const part of match[2].split(",")) {
|
|
3297
|
+
const [name, local] = part.trim().split(/\s+as\s+/);
|
|
3298
|
+
if (/^\w+$/.test(name)) alias.set(local || name, `${match[1]}.${name}`);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
const expand = (dotted) => {
|
|
3302
|
+
const [head, ...rest] = dotted.split(".");
|
|
3303
|
+
return rest.length && alias.has(head) ? [alias.get(head), ...rest].join(".") : dotted;
|
|
3304
|
+
};
|
|
3305
|
+
const expandObject = (dotted) => {
|
|
3306
|
+
const [head, ...rest] = dotted.split(".");
|
|
3307
|
+
return alias.has(head) ? [alias.get(head), ...rest].join(".") : dotted;
|
|
3308
|
+
};
|
|
3309
|
+
const codeLines2 = code.split("\n");
|
|
3310
|
+
const indent = (line) => line.length - line.trimStart().length;
|
|
3311
|
+
const functions = [];
|
|
3312
|
+
codeLines2.forEach((line, i) => {
|
|
3313
|
+
if (!/^\s*(?:async\s+)?def\s+\w+/.test(line)) return;
|
|
3314
|
+
let end = i;
|
|
3315
|
+
for (let j = i + 1; j < codeLines2.length; j++) {
|
|
3316
|
+
if (!codeLines2[j].trim()) continue;
|
|
3317
|
+
if (indent(codeLines2[j]) <= indent(line)) break;
|
|
3318
|
+
end = j;
|
|
3319
|
+
}
|
|
3320
|
+
functions.push({ range: [i, end], header: i });
|
|
3321
|
+
});
|
|
3322
|
+
const lineIndex = (offset) => lineOf2(offset) - 1;
|
|
3323
|
+
const scopeOf = (line) => {
|
|
3324
|
+
if (/^\s*@/.test(codeLines2[line] ?? "")) {
|
|
3325
|
+
for (let j = line; j < codeLines2.length && j < line + 40; j++) {
|
|
3326
|
+
const owner = functions.find((fn) => fn.header === j);
|
|
3327
|
+
if (owner) return owner.range;
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
return innermost(functions.map((fn) => fn.range), line);
|
|
3331
|
+
};
|
|
3332
|
+
const blockOf = (line) => {
|
|
3333
|
+
if (!/^\s*with\b/.test(codeLines2[line] ?? "")) return null;
|
|
3334
|
+
let end = line;
|
|
3335
|
+
for (let j = line + 1; j < codeLines2.length; j++) {
|
|
3336
|
+
if (!codeLines2[j].trim()) continue;
|
|
3337
|
+
if (indent(codeLines2[j]) <= indent(codeLines2[line])) break;
|
|
3338
|
+
end = j;
|
|
3339
|
+
}
|
|
3340
|
+
return [line, end];
|
|
3341
|
+
};
|
|
3342
|
+
const patched = /* @__PURE__ */ new Map();
|
|
3343
|
+
const patchCall = /(?<![\w.])(?:(?:mock|unittest\s*\.\s*mock)\s*\.\s*)?patch(\s*\.\s*object)?\s*\(|(?<![\w.])mocker\s*\.\s*patch(\s*\.\s*object)?\s*\(|(?<![\w.])monkeypatch\s*\.\s*setattr\s*\(/g;
|
|
3344
|
+
for (const match of code.matchAll(patchCall)) {
|
|
3345
|
+
const open2 = match.index + match[0].length - 1;
|
|
3346
|
+
const end = closing(code, open2);
|
|
3347
|
+
if (end < 0) continue;
|
|
3348
|
+
const args = raw.slice(open2 + 1, end);
|
|
3349
|
+
if (/\bwraps\s*=/.test(args)) continue;
|
|
3350
|
+
const target = STRING_AFTER.exec(args);
|
|
3351
|
+
const parts = target ? null : /^\s*([A-Za-z_][\w.]*)\s*,\s*(['"])(\w+)\2/.exec(args);
|
|
3352
|
+
const key = target ? target[2] : parts ? `${expandObject(parts[1])}.${parts[3]}` : void 0;
|
|
3353
|
+
if (!key || !DOTTED.test(key)) continue;
|
|
3354
|
+
const line = lineIndex(match.index);
|
|
3355
|
+
patched.set(key, [...patched.get(key) ?? [], { index: match.index, scope: blockOf(line) ?? scopeOf(line) }]);
|
|
3356
|
+
}
|
|
3357
|
+
if (!patched.size) return [];
|
|
3358
|
+
const found = /* @__PURE__ */ new Map();
|
|
3359
|
+
const assertion = /^[ \t]*assert\s+(?:not\s+)?([A-Za-z_][\w.]*)\s*(\()?|\bself\s*\.\s*assert\w*\s*\(\s*([A-Za-z_][\w.]*)\s*(\()?/gm;
|
|
3360
|
+
for (const match of code.matchAll(assertion)) {
|
|
3361
|
+
let callee = (match[1] ?? match[3]) && (match[2] ?? match[4]) ? match[1] ?? match[3] : void 0;
|
|
3362
|
+
const variable = !callee ? match[1] ?? match[3] : void 0;
|
|
3363
|
+
if (!callee && variable && /^[A-Za-z_]\w*$/.test(variable)) {
|
|
3364
|
+
const owner = scopeOf(lineIndex(match.index));
|
|
3365
|
+
const from = owner ? starts[owner[0]] ?? 0 : 0;
|
|
3366
|
+
const assigned = [...code.slice(from, match.index).matchAll(new RegExp(`^[ \\t]*${variable}\\s*=(?!=)\\s*(?:await\\s+)?([A-Za-z_][\\w.]*)\\s*\\(`, "gm"))].at(-1);
|
|
3367
|
+
callee = assigned?.[1];
|
|
3368
|
+
}
|
|
3369
|
+
if (!callee) continue;
|
|
3370
|
+
const key = expand(callee);
|
|
3371
|
+
if (found.has(key)) continue;
|
|
3372
|
+
const at = lineIndex(match.index);
|
|
3373
|
+
const patch = patched.get(key)?.find((item) => item.scope && within(item.scope, at) && (at >= lineIndex(item.index) || /^\s*@/.test(codeLines2[lineIndex(item.index)] ?? "")));
|
|
3374
|
+
if (!patch) continue;
|
|
3375
|
+
const line = lineOf2(patch.index);
|
|
3376
|
+
found.set(key, {
|
|
3377
|
+
key,
|
|
3378
|
+
line,
|
|
3379
|
+
snippet: lines[line - 1].trim().slice(0, 120),
|
|
3380
|
+
reason: `assertion on the output of ${callee}(), which this test replaces with a mock`
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
return [...found.values()];
|
|
3384
|
+
}
|
|
3385
|
+
function mockedSubjects(text, file) {
|
|
3386
|
+
if (/mock|monkeypatch/i.test(file)) return [];
|
|
3387
|
+
if (/\.[cm]?[jt]sx?$/i.test(file)) return javascript(text);
|
|
3388
|
+
if (/\.py$/i.test(file)) return python(text);
|
|
3389
|
+
return [];
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
// src/pyconfig.ts
|
|
3393
|
+
function openBrackets(text) {
|
|
3394
|
+
const bare = text.replace(/"(?:\\.|[^"\\\n])*"|'[^'\n]*'/g, '""').replace(/#.*$/gm, "");
|
|
3395
|
+
let depth = 0;
|
|
3396
|
+
for (const char of bare) {
|
|
3397
|
+
if (char === "[" || char === "{") depth++;
|
|
3398
|
+
else if (char === "]" || char === "}") depth--;
|
|
3399
|
+
}
|
|
3400
|
+
return depth;
|
|
3401
|
+
}
|
|
3402
|
+
function configEntries(text, toml) {
|
|
3403
|
+
const lines = text.split(/\r?\n/);
|
|
3404
|
+
const entries = [];
|
|
3405
|
+
let section = "";
|
|
3406
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3407
|
+
const trimmed = lines[i].trim();
|
|
3408
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
|
|
3409
|
+
const header = /^\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(trimmed);
|
|
3410
|
+
if (header) {
|
|
3411
|
+
section = header[1].replace(/["']/g, "");
|
|
3412
|
+
continue;
|
|
3413
|
+
}
|
|
3414
|
+
const pair = /^("[^"]*"|'[^']*'|[\w.-]+)\s*=\s*(.*)$/.exec(trimmed);
|
|
3415
|
+
if (!pair) continue;
|
|
3416
|
+
const start = i;
|
|
3417
|
+
let value = pair[2];
|
|
3418
|
+
if (toml) {
|
|
3419
|
+
while (openBrackets(value) > 0 && i + 1 < lines.length) value += "\n" + lines[++i];
|
|
3420
|
+
} else {
|
|
3421
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]) && !/^\s*[#;]/.test(lines[i + 1])) value += "\n" + lines[++i].trim();
|
|
3422
|
+
}
|
|
3423
|
+
entries.push({ section, key: pair[1].replace(/["']/g, ""), value, line: start + 1 });
|
|
3424
|
+
}
|
|
3425
|
+
return entries;
|
|
3426
|
+
}
|
|
3427
|
+
function members(value) {
|
|
3428
|
+
const quoted = [...value.matchAll(/"((?:\\.|[^"\\\n])*)"|'([^'\n]*)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
|
|
3429
|
+
if (quoted.length) return quoted;
|
|
3430
|
+
return value.replace(/[#;].*$/gm, "").split(/[\s,]+/).map((word) => word.trim()).filter(Boolean);
|
|
3431
|
+
}
|
|
3432
|
+
function covers(selector, code) {
|
|
3433
|
+
if (selector === "ALL") return true;
|
|
3434
|
+
const wanted = /^([A-Za-z]+)(\d*)$/.exec(selector), actual = /^([A-Za-z]+)(\d*)$/.exec(code);
|
|
3435
|
+
if (!wanted || !actual) return code.startsWith(selector);
|
|
3436
|
+
const linter = (letters) => letters.toUpperCase().replace(/^TCH$/, "TC");
|
|
3437
|
+
return linter(wanted[1]) === linter(actual[1]) && actual[2].startsWith(wanted[2]);
|
|
3438
|
+
}
|
|
3439
|
+
var truthy = (value) => /^(?:true|1|yes|on)$/i.test(value.replace(/\s*[#;].*$/, "").trim());
|
|
3440
|
+
function toolOf(file, section) {
|
|
3441
|
+
const name = section.toLowerCase();
|
|
3442
|
+
if (/(?:^|\/)\.?ruff\.toml$/i.test(file) || name.startsWith("tool.ruff")) return "ruff";
|
|
3443
|
+
if (name === "flake8" || /(?:^|\/)\.flake8$/i.test(file)) return "flake8";
|
|
3444
|
+
if (/messages[ _]control/.test(name)) return "pylint";
|
|
3445
|
+
if (name === "mypy" || name.startsWith("mypy-") || name.startsWith("tool.mypy") || /(?:^|\/)\.?mypy\.ini$/i.test(file)) return "mypy";
|
|
3446
|
+
return null;
|
|
3447
|
+
}
|
|
3448
|
+
var IGNORE_LISTS = {
|
|
3449
|
+
ruff: /^(?:extend-)?(?:ignore|per-file-ignores)$/,
|
|
3450
|
+
flake8: /^(?:extend[-_])?ignore$|^per[-_]file[-_]ignores$/,
|
|
3451
|
+
pylint: /^disable$/,
|
|
3452
|
+
mypy: /^disable_error_code$/
|
|
3453
|
+
};
|
|
3454
|
+
var MYPY_SWITCHES = /^(?:strict|disallow_untyped_defs|disallow_incomplete_defs|disallow_untyped_calls|disallow_any_generics|disallow_untyped_decorators|disallow_subclassing_any|check_untyped_defs|warn_return_any|warn_unreachable|warn_redundant_casts|no_implicit_optional|strict_optional)$/;
|
|
3455
|
+
var looksLikeCode = (word) => !/[/*:]/.test(word);
|
|
3456
|
+
function analyzePythonConfig(file, oldText, newText, report) {
|
|
3457
|
+
const toml = /\.toml$/i.test(file);
|
|
3458
|
+
const before = configEntries(oldText, toml), after = configEntries(newText, toml);
|
|
3459
|
+
const configured = new Set(before.map((entry) => toolOf(file, entry.section)).filter(Boolean));
|
|
3460
|
+
const ignored = (entries) => {
|
|
3461
|
+
const found = /* @__PURE__ */ new Map();
|
|
3462
|
+
for (const entry of entries) {
|
|
3463
|
+
const tool = toolOf(file, entry.section);
|
|
3464
|
+
if (!tool) continue;
|
|
3465
|
+
const perFile = tool === "ruff" ? /per-file-ignores$/i.test(entry.section) : false;
|
|
3466
|
+
if (!perFile && !IGNORE_LISTS[tool].test(entry.key)) continue;
|
|
3467
|
+
const bucket = found.get(tool) ?? /* @__PURE__ */ new Map();
|
|
3468
|
+
found.set(tool, bucket);
|
|
3469
|
+
for (const code of members(entry.value)) if (looksLikeCode(code) && !bucket.has(code)) bucket.set(code, entry.line);
|
|
3470
|
+
}
|
|
3471
|
+
return found;
|
|
3472
|
+
};
|
|
3473
|
+
const was = ignored(before);
|
|
3474
|
+
const enforced = (tool, code) => {
|
|
3475
|
+
if (tool !== "ruff" && tool !== "flake8") return true;
|
|
3476
|
+
const chosen = (key) => before.filter((entry) => toolOf(file, entry.section) === tool && key.test(entry.key)).flatMap((entry) => members(entry.value));
|
|
3477
|
+
const explicit = chosen(/^select$/), extra = chosen(/^extend[-_]select$/);
|
|
3478
|
+
const base = explicit.length ? explicit : tool === "ruff" ? ["E4", "E7", "E9", "F"] : ["E", "F", "W", "C90"];
|
|
3479
|
+
return [...base, ...extra].some((selected2) => covers(selected2, code) || covers(code, selected2));
|
|
3480
|
+
};
|
|
3481
|
+
for (const [tool, bucket] of ignored(after)) {
|
|
3482
|
+
if (!configured.has(tool)) continue;
|
|
3483
|
+
const old = [...was.get(tool)?.keys() ?? []];
|
|
3484
|
+
const fresh = [...bucket].filter(([code]) => !old.some((known) => covers(known, code)) && enforced(tool, code));
|
|
3485
|
+
if (fresh.length) report(Math.min(...fresh.map(([, line]) => line)), `${tool} ignore: ${fresh.slice(0, 4).map(([code]) => "+" + code).join(", ")}${fresh.length > 4 ? ", \u2026" : ""}`, "Lint or type checks switched off in the configuration");
|
|
3486
|
+
}
|
|
3487
|
+
const selected = (entries, tool) => entries.filter((entry) => toolOf(file, entry.section) === tool && entry.key === "select").flatMap((entry) => members(entry.value));
|
|
3488
|
+
for (const tool of ["ruff", "flake8"]) {
|
|
3489
|
+
const old = selected(before, tool), now = selected(after, tool);
|
|
3490
|
+
if (!old.length || !now.length || now.includes("ALL")) continue;
|
|
3491
|
+
const dropped = old.filter((code) => !now.some((kept) => covers(kept, code)));
|
|
3492
|
+
if (dropped.length) {
|
|
3493
|
+
const line = after.find((entry) => toolOf(file, entry.section) === tool && entry.key === "select")?.line ?? 1;
|
|
3494
|
+
report(line, `${tool} select: ${dropped.slice(0, 4).map((code) => "-" + code).join(", ")}${dropped.length > 4 ? ", \u2026" : ""}`, "Lint rules removed from the selected set");
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
const switches = new Map(before.filter((entry) => toolOf(file, entry.section) === "mypy" && MYPY_SWITCHES.test(entry.key)).map((entry) => [`${entry.section}|${entry.key}`, truthy(entry.value)]));
|
|
3498
|
+
for (const entry of after) {
|
|
3499
|
+
if (toolOf(file, entry.section) !== "mypy" || !MYPY_SWITCHES.test(entry.key)) continue;
|
|
3500
|
+
if (switches.get(`${entry.section}|${entry.key}`) === true && !truthy(entry.value)) report(entry.line, `mypy ${entry.key}: true \u2192 false`, "Type checker strictness switched off");
|
|
3501
|
+
}
|
|
3502
|
+
const ignoring = (entries) => entries.filter((entry) => toolOf(file, entry.section) === "mypy" && entry.key === "ignore_errors" && truthy(entry.value));
|
|
3503
|
+
const before_ = ignoring(before), after_ = ignoring(after);
|
|
3504
|
+
if (configured.has("mypy") && after_.length > before_.length) {
|
|
3505
|
+
const known = new Set(before_.map((entry) => entry.section));
|
|
3506
|
+
const fresh = after_.find((entry) => !known.has(entry.section)) ?? after_[after_.length - 1];
|
|
3507
|
+
report(fresh.line, `mypy ignore_errors = true (${fresh.section})`, "Type errors ignored for a module");
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/tampering.ts
|
|
3103
3512
|
var EMPTY = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
3104
3513
|
function languageOf(file) {
|
|
3105
3514
|
if (/\.(?:[cm]?[jt]sx?)$/i.test(file)) return "js";
|
|
@@ -3259,14 +3668,45 @@ function parseTests(text, language) {
|
|
|
3259
3668
|
function skeleton(assertion) {
|
|
3260
3669
|
return assertion.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "S").replace(/\b\d+(?:\.\d+)?\b/g, "N").replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "V").replace(/\s+/g, "");
|
|
3261
3670
|
}
|
|
3262
|
-
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)/;
|
|
3671
|
+
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)|(?<!\.not)\.(?:toHaveBeenCalled|toBeCalled)\s*\(\s*\)|\.(?:toBe|toEqual|toStrictEqual)\s*\(\s*expect\s*\.\s*(?:anything\s*\(\s*\)|any\s*\(\s*\w+\s*\))\s*\)/;
|
|
3672
|
+
var BOUND_JS = /\.toBeGreaterThan\s*\(\s*(?:0|-1)\s*\)|\.toBeGreaterThanOrEqual\s*\(\s*[01]\s*\)/;
|
|
3673
|
+
var ZERO_JS = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toHaveBeenCalledTimes)\s*\(\s*(?:0|\[\s*\]|\{\s*\}|null|undefined|false|"")\s*\)/;
|
|
3263
3674
|
var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
|
|
3675
|
+
var BOUND_PY = /^\s*assert\s+.+(?:>\s*0|>=\s*[01])\s*(?:,.*)?$/;
|
|
3676
|
+
var EMPTY_PY = /==\s*(?:0|\[\s*\]|\{\s*\}|""|''|None|False)\b|\bis\s+None\b|\bnot\b|!=\s*(?:0|None)\b/;
|
|
3264
3677
|
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
3265
|
-
var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
|
|
3678
|
+
var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toBeCalledWith|toHaveBeenCalledTimes|toHaveBeenCalledOnce|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
|
|
3266
3679
|
var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
|
|
3680
|
+
function pinsLess(oldAssertion, newAssertion, language) {
|
|
3681
|
+
if (language === "js") {
|
|
3682
|
+
const was = /\.toThrow(?:Error)?\s*\(\s*([^)\s][^)]*)\)/.exec(oldAssertion);
|
|
3683
|
+
const now = /\.toThrow(?:Error)?\s*\(\s*([^)]*)\)/.exec(newAssertion);
|
|
3684
|
+
return !!was && !!now && (now[1].trim() === "" || now[1].trim() === "Error" && was[1].trim() !== "Error");
|
|
3685
|
+
}
|
|
3686
|
+
if (language === "py") {
|
|
3687
|
+
const raised = (text) => {
|
|
3688
|
+
const captured = /\)\s*as\s+\w+/.test(text);
|
|
3689
|
+
const marker = /\bpytest\s*\.\s*raises\s*\(\s*([\w.]+|\([^)]*\))([^)]*)\)/.exec(text);
|
|
3690
|
+
if (marker) return { type: marker[1], message: /\bmatch\s*=/.test(marker[2]), captured };
|
|
3691
|
+
const unit = /\bassertRaises(Regex)?\s*\(\s*([\w.]+|\([^)]*\))/.exec(text);
|
|
3692
|
+
return unit ? { type: unit[2], message: !!unit[1], captured } : null;
|
|
3693
|
+
};
|
|
3694
|
+
const was = raised(oldAssertion), now = raised(newAssertion);
|
|
3695
|
+
const broad = (type) => /^(?:builtins\.)?(?:Base)?Exception$/.test(type);
|
|
3696
|
+
return !!was && !!now && (was.message && !now.message && !now.captured || !broad(was.type) && broad(now.type));
|
|
3697
|
+
}
|
|
3698
|
+
return false;
|
|
3699
|
+
}
|
|
3267
3700
|
function isWeaker(oldAssertion, newAssertion, language) {
|
|
3268
|
-
if (
|
|
3269
|
-
if (language === "
|
|
3701
|
+
if (pinsLess(oldAssertion, newAssertion, language)) return true;
|
|
3702
|
+
if (language === "js") {
|
|
3703
|
+
if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
|
|
3704
|
+
return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion);
|
|
3705
|
+
}
|
|
3706
|
+
if (language === "py") {
|
|
3707
|
+
if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || !PY_SPECIFIC.test(oldAssertion)) return false;
|
|
3708
|
+
return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|\bin\b|[<>]=?/.test(oldAssertion);
|
|
3709
|
+
}
|
|
3270
3710
|
return false;
|
|
3271
3711
|
}
|
|
3272
3712
|
var GENERIC_ARGUMENT = /^(?:not\.)?(?:be|have|contain|include|exist|match|eq|equal|length|text|value|attr|class|css|visible|checked|disabled|enabled|selected|focus|empty|true|false|null|undefined|deep)(?:\.\w+)*$/;
|
|
@@ -3301,6 +3741,10 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
3301
3741
|
const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
|
|
3302
3742
|
const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
3303
3743
|
for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
|
|
3744
|
+
const mockedBefore = new Set((oldText === null ? [] : mockedSubjects(oldText, oldPath)).map((item) => item.key));
|
|
3745
|
+
for (const item of newText === null ? [] : mockedSubjects(newText, newPath)) {
|
|
3746
|
+
if (!mockedBefore.has(item.key)) result.immediate.push(finding("trivial-assert", newPath, item.line, item.snippet, item.reason, commit));
|
|
3747
|
+
}
|
|
3304
3748
|
for (const old of before) {
|
|
3305
3749
|
const current = afterByKey.get(old.key);
|
|
3306
3750
|
if (!current) {
|
|
@@ -3349,7 +3793,20 @@ ${addedText}`;
|
|
|
3349
3793
|
continue;
|
|
3350
3794
|
}
|
|
3351
3795
|
if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
|
|
3352
|
-
const
|
|
3796
|
+
const left = (from, against) => {
|
|
3797
|
+
const budget = /* @__PURE__ */ new Map();
|
|
3798
|
+
for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
|
|
3799
|
+
return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
|
|
3800
|
+
const available = budget.get(assertion) ?? 0;
|
|
3801
|
+
if (available > 0) {
|
|
3802
|
+
budget.set(assertion, available - 1);
|
|
3803
|
+
return false;
|
|
3804
|
+
}
|
|
3805
|
+
return true;
|
|
3806
|
+
}).map(({ i }) => i);
|
|
3807
|
+
};
|
|
3808
|
+
const goneIndexes = left(old.assertions, current.assertions), freshIndexes = left(current.assertions, old.assertions);
|
|
3809
|
+
const changed = goneIndexes.map((index, k) => ({ before: old.assertions[index], after: current.assertions[freshIndexes[k]], rawBefore: old.raw[index], rawAfter: current.raw[freshIndexes[k]] })).filter((pair) => pair.after !== void 0);
|
|
3353
3810
|
const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
|
|
3354
3811
|
if (weaker) {
|
|
3355
3812
|
result.immediate.push(finding(
|
|
@@ -3382,7 +3839,58 @@ function resolveTampering(files, productionChanged) {
|
|
|
3382
3839
|
...productionChanged ? files.flatMap((file) => file.rewritten) : []
|
|
3383
3840
|
];
|
|
3384
3841
|
}
|
|
3385
|
-
|
|
3842
|
+
function isKnownFailureMarker(finding2) {
|
|
3843
|
+
return finding2.type === "test-skip" && /\bxfail\b|\bexpectedFailure\b|\bfixme\b|expected to fail/i.test(finding2.reason);
|
|
3844
|
+
}
|
|
3845
|
+
function markedTest(finding2, lines) {
|
|
3846
|
+
const snippet = finding2.lineSnippet ?? "";
|
|
3847
|
+
const index = (finding2.lineNumber ?? 1) - 1;
|
|
3848
|
+
if (/\.py$/i.test(finding2.file ?? "")) {
|
|
3849
|
+
if (/^\s*pytestmark\b/.test(snippet)) return null;
|
|
3850
|
+
if (/imperative/.test(finding2.reason)) {
|
|
3851
|
+
const indent = (lines[index] ?? "").search(/\S/);
|
|
3852
|
+
for (let i = index - 1; i >= 0 && i > index - 200; i--) {
|
|
3853
|
+
const def = /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/.exec(lines[i]);
|
|
3854
|
+
if (def && def[1].length < indent) return { key: `py:${def[2]}`, title: def[2] };
|
|
3855
|
+
}
|
|
3856
|
+
return null;
|
|
3857
|
+
}
|
|
3858
|
+
for (let i = index; i < lines.length && i < index + 80; i++) {
|
|
3859
|
+
const def = /^\s*(?:async\s+)?def\s+(\w+)\s*\(/.exec(lines[i]);
|
|
3860
|
+
if (def) return { key: `py:${def[1]}`, title: def[1] };
|
|
3861
|
+
}
|
|
3862
|
+
return null;
|
|
3863
|
+
}
|
|
3864
|
+
if (/\.each\b/.test(snippet)) return null;
|
|
3865
|
+
const title = /(['"`])((?:\\.|(?!\1)[^\\\n])+)\1/.exec(snippet);
|
|
3866
|
+
return title ? { key: `js:${title[0]}`, title: title[0] } : null;
|
|
3867
|
+
}
|
|
3868
|
+
function newKnownFailureMarkers(before, after, oldText, newText) {
|
|
3869
|
+
const markers = after.filter(isKnownFailureMarker);
|
|
3870
|
+
if (!markers.length || oldText === null) return [];
|
|
3871
|
+
const oldLines = oldText.split(/\r?\n/), newLines = (newText ?? "").split(/\r?\n/);
|
|
3872
|
+
const existed = (test) => test.key.startsWith("py:") ? new RegExp(`^\\s*(?:async\\s+)?def\\s+${test.title}\\s*\\(`, "m").test(oldText) : oldText.includes(test.title);
|
|
3873
|
+
const markedBefore = /* @__PURE__ */ new Set();
|
|
3874
|
+
let unnamedBefore = 0;
|
|
3875
|
+
for (const finding2 of before.filter(isKnownFailureMarker)) {
|
|
3876
|
+
const test = markedTest(finding2, oldLines);
|
|
3877
|
+
if (test) markedBefore.add(test.key);
|
|
3878
|
+
else unnamedBefore++;
|
|
3879
|
+
}
|
|
3880
|
+
const found = [], unnamed = [], seen = /* @__PURE__ */ new Set();
|
|
3881
|
+
for (const finding2 of markers) {
|
|
3882
|
+
const test = markedTest(finding2, newLines);
|
|
3883
|
+
if (!test) {
|
|
3884
|
+
unnamed.push(finding2);
|
|
3885
|
+
continue;
|
|
3886
|
+
}
|
|
3887
|
+
if (markedBefore.has(test.key) || seen.has(test.key) || !existed(test)) continue;
|
|
3888
|
+
seen.add(test.key);
|
|
3889
|
+
found.push(finding2);
|
|
3890
|
+
}
|
|
3891
|
+
return [...found, ...unnamed.slice(unnamedBefore)];
|
|
3892
|
+
}
|
|
3893
|
+
var GUARD_FILE = /(?:^|\/)(?:package\.json|tsconfig[^/]*\.json|jsconfig\.json|(?:jest|vitest|vite)\.config\.[cm]?[jt]s|\.eslintrc(?:\.[a-z]+)?|eslint\.config\.[cm]?[jt]s|pyproject\.toml|setup\.cfg|pytest\.ini|tox\.ini|\.?ruff\.toml|\.flake8|\.?mypy\.ini|\.?pylintrc|\.coveragerc|codecov\.ya?ml|\.gitlab-ci\.ya?ml|\.github\/workflows\/[^/]+\.ya?ml)$/i;
|
|
3386
3894
|
function isGuardFile(file) {
|
|
3387
3895
|
return GUARD_FILE.test(file);
|
|
3388
3896
|
}
|
|
@@ -3403,9 +3911,13 @@ function numbers(text, pattern) {
|
|
|
3403
3911
|
var THRESHOLD_PATTERNS = [
|
|
3404
3912
|
/\b(lines|branches|functions|statements)\b["']?\s*[:=]\s*(\d+(?:\.\d+)?)/g,
|
|
3405
3913
|
/\b(fail_under)\s*[:=]\s*(\d+(?:\.\d+)?)/g,
|
|
3406
|
-
/(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
|
|
3914
|
+
/(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g,
|
|
3915
|
+
// `coverage report --fail-under=90` on a command line, and pylint's `fail-under=9.5` in its own config (a lint score).
|
|
3916
|
+
/(?<![\w-])(--fail-under)[= ](\d+(?:\.\d+)?)/g,
|
|
3917
|
+
/(?<![\w-])(fail-under)\s*[:=]\s*(\d+(?:\.\d+)?)/g
|
|
3407
3918
|
];
|
|
3408
3919
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
3920
|
+
var GATE_SCRIPT = /^(?:pre|post)?(?:test|lint|typecheck|type-check|check|ci|verify|validate)(?::[\w:.-]+)?$/;
|
|
3409
3921
|
var CI_RUNNER = new RegExp(
|
|
3410
3922
|
String.raw`(?<![\w./-])(?:pytest|vitest|jest|mocha|tox|nox)(?![\w/-])` + String.raw`|(?<![\w./-])(?:npm|yarn|pnpm|bun|go|cargo|mvn|gradle|make|dotnet|flutter)\s+(?:run\s+)?(?:-\S+\s+)*test(?![\w/])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
|
|
3411
3923
|
);
|
|
@@ -3430,7 +3942,8 @@ function resolveCiTestSteps(counts, commit) {
|
|
|
3430
3942
|
if (after >= before) return [];
|
|
3431
3943
|
return counts.filter((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0).map((item) => item.ungatedAfter > 0 ? finding("guard-weakened", item.file, 1, "tests no longer run on push or pull_request", "Workflow keeps its tests but no longer runs on pushes or pull requests", commit) : finding("guard-weakened", item.file, 1, "test command no longer runs in CI", "Test step removed from CI", commit));
|
|
3432
3944
|
}
|
|
3433
|
-
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true
|
|
3945
|
+
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true|^\s*(?:-\s+)?if:\s*(?:\$\{\{\s*)?false\s*(?:\}\})?\s*(?:#.*)?$/;
|
|
3946
|
+
var SHELL_SWALLOW = /\|\|\s*(?:true|exit\s+0|:|echo\b)|;\s*(?:true|exit\s+0)\b/;
|
|
3434
3947
|
function lenientSteps(text) {
|
|
3435
3948
|
const lines = text.split(/\r?\n/);
|
|
3436
3949
|
const indentOf = (line) => line.length - line.trimStart().length;
|
|
@@ -3438,8 +3951,15 @@ function lenientSteps(text) {
|
|
|
3438
3951
|
lines.forEach((line, i) => {
|
|
3439
3952
|
if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
|
|
3440
3953
|
let j = i - 1;
|
|
3441
|
-
|
|
3442
|
-
|
|
3954
|
+
let identity;
|
|
3955
|
+
if (/^\s*-\s+if:/.test(line)) {
|
|
3956
|
+
j = i + 1;
|
|
3957
|
+
while (j < lines.length && (!lines[j].trim() || lines[j].trim().startsWith("#"))) j++;
|
|
3958
|
+
identity = j < lines.length ? lines[j].trim() : "";
|
|
3959
|
+
} else {
|
|
3960
|
+
while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
|
|
3961
|
+
identity = j >= 0 ? lines[j].trim() : "";
|
|
3962
|
+
}
|
|
3443
3963
|
const entry = found.get(identity);
|
|
3444
3964
|
if (entry) entry.count++;
|
|
3445
3965
|
else found.set(identity, { count: 1, line: i + 1 });
|
|
@@ -3453,22 +3973,25 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
3453
3973
|
if (oldText === null || newText === null) return [];
|
|
3454
3974
|
const out = [];
|
|
3455
3975
|
const add = (index, snippet, reason) => out.push(finding("guard-weakened", file, lineOf(newText, index), snippet, reason, commit));
|
|
3456
|
-
if (/threshold|
|
|
3976
|
+
if (/threshold|fail[_-]under|coverage/i.test(newText)) {
|
|
3457
3977
|
for (const pattern of THRESHOLD_PATTERNS) {
|
|
3458
3978
|
const old = new Map(numbers(oldText, pattern).map((item) => [item.key, item.value]));
|
|
3459
3979
|
for (const item of numbers(newText, pattern)) {
|
|
3460
3980
|
const previous = old.get(item.key);
|
|
3461
3981
|
if (previous !== void 0 && item.value < previous) {
|
|
3462
|
-
add(item.index, `${item.key.split("#")[0]}: ${previous} \u2192 ${item.value}`, "Coverage threshold lowered");
|
|
3982
|
+
add(item.index, `${item.key.split("#")[0]}: ${previous} \u2192 ${item.value}`, item.key.startsWith("fail-under#") ? "Lint score threshold lowered" : "Coverage threshold lowered");
|
|
3463
3983
|
}
|
|
3464
3984
|
}
|
|
3465
3985
|
}
|
|
3466
3986
|
}
|
|
3467
3987
|
if (/(?:^|\/)(?:tsconfig|jsconfig)[^/]*\.json$/i.test(file)) {
|
|
3988
|
+
const setting = (text, name) => new RegExp(`"${name}"\\s*:\\s*(true|false)`).exec(text);
|
|
3989
|
+
const strictBefore = setting(oldText, "strict")?.[1] === "true";
|
|
3468
3990
|
for (const name of SWITCHES) {
|
|
3469
|
-
const
|
|
3470
|
-
|
|
3471
|
-
|
|
3991
|
+
const before = setting(oldText, name), after = setting(newText, name);
|
|
3992
|
+
if (after?.[1] === "false" && (before?.[1] === "true" || before === null && strictBefore && name !== "strict")) {
|
|
3993
|
+
add(after.index, `${name}: ${before ? "true \u2192 false" : "false (strict stays on)"}`, "Compiler strictness switched off");
|
|
3994
|
+
}
|
|
3472
3995
|
}
|
|
3473
3996
|
}
|
|
3474
3997
|
if (/eslint/i.test(file)) {
|
|
@@ -3488,20 +4011,50 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
3488
4011
|
if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
|
|
3489
4012
|
}
|
|
3490
4013
|
}
|
|
4014
|
+
if (/(?:^|\/)(?:pytest\.ini|tox\.ini|setup\.cfg|pyproject\.toml)$/i.test(file)) {
|
|
4015
|
+
const strictness = (text) => /^[ \t]*xfail_strict[ \t]*=[ \t]*(true|false|1|0|yes|no|on|off)\b/im.exec(text);
|
|
4016
|
+
const before = strictness(oldText), after = strictness(newText);
|
|
4017
|
+
if (before && after && /^(?:true|1|yes|on)$/i.test(before[1]) && /^(?:false|0|no|off)$/i.test(after[1])) {
|
|
4018
|
+
add(after.index, "xfail_strict: true \u2192 false", "Strict xfail switched off");
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
if (/(?:^|\/)(?:pyproject\.toml|setup\.cfg|tox\.ini|\.?ruff\.toml|\.flake8|\.?mypy\.ini|\.?pylintrc)$/i.test(file)) {
|
|
4022
|
+
analyzePythonConfig(file, oldText, newText, (line, snippet, reason) => {
|
|
4023
|
+
out.push(finding("guard-weakened", file, line, snippet, reason, commit));
|
|
4024
|
+
});
|
|
4025
|
+
}
|
|
3491
4026
|
if (CI_FILE.test(file)) {
|
|
3492
|
-
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
4027
|
+
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).flatMap((line) => [line.trim(), line.trim().replace(/^-\s+/, "")]));
|
|
4028
|
+
const newLines = newText.split(/\r?\n/);
|
|
4029
|
+
for (const [identity, now2] of lenientSteps(newText)) {
|
|
4030
|
+
if (identity && existing.has(identity) && now2.count > (before.get(identity)?.count ?? 0)) {
|
|
4031
|
+
const marker = (newLines[now2.line - 1] ?? "").trim();
|
|
4032
|
+
out.push(finding("guard-weakened", file, now2.line, marker, /^(?:-\s+)?if:/.test(marker) ? "CI step is now switched off" : "CI step is now allowed to fail", commit));
|
|
3496
4033
|
break;
|
|
3497
4034
|
}
|
|
3498
4035
|
}
|
|
4036
|
+
const swallowing = (text) => text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter((item) => !item.line.startsWith("#") && CI_RUNNER.test(item.line) && SHELL_SWALLOW.test(item.line));
|
|
4037
|
+
const was = swallowing(oldText), now = swallowing(newText);
|
|
4038
|
+
const tolerance = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
|
|
4039
|
+
const traded = Math.max(0, tolerance(oldText) - tolerance(newText));
|
|
4040
|
+
if (now.length > was.length + traded) {
|
|
4041
|
+
const known = new Set(was.map((item) => item.line));
|
|
4042
|
+
const fresh = now.find((item) => !known.has(item.line)) ?? now[0];
|
|
4043
|
+
out.push(finding("guard-weakened", file, fresh.index + 1, fresh.line.slice(0, 80), "Test command can no longer fail the CI step", commit));
|
|
4044
|
+
}
|
|
3499
4045
|
}
|
|
3500
4046
|
if (/(?:^|\/)package\.json$/i.test(file)) {
|
|
3501
4047
|
try {
|
|
3502
|
-
const before = JSON.parse(oldText)?.scripts
|
|
3503
|
-
|
|
3504
|
-
|
|
4048
|
+
const before = JSON.parse(oldText)?.scripts, after = JSON.parse(newText)?.scripts;
|
|
4049
|
+
const noop = (script) => /^\s*(?:echo\b|true\b|exit\s+0|:)/.test(script) || /\|\|\s*(?:true|exit\s+0)\b/.test(script);
|
|
4050
|
+
if (before && after) for (const name of Object.keys(after)) {
|
|
4051
|
+
const was = before[name], now = after[name];
|
|
4052
|
+
if (!GATE_SCRIPT.test(name) || typeof was !== "string" || typeof now !== "string" || was === now) continue;
|
|
4053
|
+
const tests = /^(?:pre|post)?test(?::|$)/.test(name);
|
|
4054
|
+
if (noop(now) && !noop(was) || tests && /--passWithNoTests/.test(now) && !/--passWithNoTests/.test(was)) {
|
|
4055
|
+
add(Math.max(0, newText.indexOf(`"${name}"`)), `${name}: ${now}`, tests ? "Test script can no longer fail" : `The ${name} script can no longer fail`);
|
|
4056
|
+
break;
|
|
4057
|
+
}
|
|
3505
4058
|
}
|
|
3506
4059
|
} catch {
|
|
3507
4060
|
}
|
|
@@ -3668,7 +4221,7 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
3668
4221
|
const fingerprint = findingFingerprint(finding2);
|
|
3669
4222
|
existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
|
|
3670
4223
|
}
|
|
3671
|
-
result.infractions.push(...compared.introduced);
|
|
4224
|
+
result.infractions.push(...compared.introduced.filter((finding2) => !isKnownFailureMarker(finding2)), ...newKnownFailureMarkers(before, after, oldText, newText));
|
|
3672
4225
|
result.removedFindings.push(...compared.removed);
|
|
3673
4226
|
result.unchangedCount += compared.unchanged;
|
|
3674
4227
|
result.beforeFindings += before.length;
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Stops AI coding agents (and rushed humans) from turning a build green by weakening tests, coverage gates or CI. Hook for Claude Code, Codex and Cursor, pre-commit, GitHub Action. Offline.",
|
|
5
5
|
"mcpName": "io.github.Nari-Ab/agent-roast",
|
|
6
6
|
"type": "module",
|