agent-roast 0.8.1 → 0.10.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  <img src="docs/assets/logo.svg" alt="agent-roast" width="380">
6
6
  </picture>
7
7
 
8
- <h2>Catch coding shortcuts before they ship</h2>
8
+ <h2>Catch weakened tests and quality gates before they ship</h2>
9
9
 
10
10
  <p>
11
11
  <a href="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml"><img src="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml/badge.svg" alt="CLI tests"></a>
@@ -17,36 +17,105 @@
17
17
 
18
18
  </div>
19
19
 
20
- `agent-roast` is an offline review aid for skipped tests, fake assertions, swallowed
21
- errors, unfinished stubs and type-check bypasses. Start with the code you have now;
22
- no AI commit tags, cloud account, API key or commit history are required for `check`.
23
- A Git repository is required; `git init` is sufficient.
20
+ **Did this change fix the code, or only make the checks pass?**
21
+
22
+ A red build can be turned green by changing the *checks* instead of the *code*: loosening or
23
+ deleting the failing assertion, removing the test, lowering the coverage threshold, switching
24
+ off `strict`, or letting a CI step fail. Nothing in the new file looks wrong, so a linter
25
+ cannot see it. `agent-roast review` compares the **before and after** of a change and reports
26
+ exactly these moves. It is offline, runs no project code, and needs only Git. It was built to
27
+ review AI-assisted changes, but it cannot tell who wrote a change, and the measurements below
28
+ show these moves are not specific to AI.
24
29
 
25
30
  ```bash
26
- # Inspect current tracked + non-ignored untracked source files
27
- npx agent-roast check
31
+ npx agent-roast review --base origin/main # a pull request
32
+ npx agent-roast --staged # what you are about to commit
33
+ ```
28
34
 
29
- # Understand a finding and what to do next
35
+ | Rule | Fires when | By default |
36
+ | --- | --- | --- |
37
+ | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x`, `isinstance(...)` → `is not None` | blocks |
38
+ | `guard-weakened` | coverage threshold lowered, `strict`/`noImplicitAny` off, lint rule downgraded, an existing CI step allowed to fail, a workflow that stops running tests or stops running on push and pull requests, a test script that cannot fail | blocks |
39
+ | `assertion-removed` | a test keeps its name but loses assertions | warns |
40
+ | `test-removed` | a test (or test file) disappears and no identical body appears elsewhere | warns |
41
+ | `expectation-rewritten` | an expected value changed, test inputs did not, and production code changed in the same PR | warns |
42
+ | `test-skip`, `test-focus`, `trivial-assert`, `swallowed-error`, `type-escape`, `stub-abandonment` | the classic shortcuts, compared before/after so old debt does not block | skip, focus and trivial-assert block |
43
+
44
+ Renames, moved tests, parametrization, assertions moved into a new helper, and formatter
45
+ rewraps (prettier, black) are recognized and not reported. Intentional cases can be accepted
46
+ one by one with an expiring, reviewed exception (see below). Policy is read from the base
47
+ branch, so a PR cannot switch the rules off for itself.
48
+
49
+ ### How well does it work?
50
+
51
+ Measured, not promised; method, corpus and raw numbers are in
52
+ [docs/benchmarks.md](docs/benchmarks.md). **Short version: weakened checks are rare, usually
53
+ deliberate, and not specific to AI. The tool finds them; a person decides.**
54
+
55
+ - **Labeled changes** (`benchmarks/cases.mjs`): 40 cheating edits across JS/TS, Python, Go and
56
+ CI/config files are caught and 33 harmless look-alikes (reformat, rename, move, parametrize,
57
+ helper extraction, raised thresholds, CI restructuring) stay silent. The cases were written
58
+ together with the detector, so they guard against regressions; they do not prove accuracy.
59
+ Three known gaps are listed in the file and still open: mocking the module under test,
60
+ hardcoding the tested value, and vacuous matchers such as `expect.anything()`.
61
+ - **Five mature open-source projects** (commander.js, execa, click, requests, zod; 606 commits
62
+ that touch tests or CI/config): the blocking rules fired on 3 commits (0.5%), all of them
63
+ deliberate. `assertion-removed` fired on 5 (0.8%), including one real defect:
64
+ a refactor in `requests` dropped the `assert` keyword and left a comparison with no effect.
65
+ - **AI-signed commits in 166 public repositories** (signatures from Claude, Copilot, Cursor,
66
+ Codex, Devin and aider; 12,645 commits that touch tests or CI/config):
67
+
68
+ | | AI-signed | Other commits |
69
+ | --- | ---: | ---: |
70
+ | Repositories | 160 | 141 |
71
+ | Commits reviewed | 6,895 | 5,750 |
72
+ | Blocking rules | 0.3% (20) | 0.3% (16) |
73
+ | `assertion-removed` (warns) | 2.4% | 2.2% |
74
+ | `test-removed` (warns) | 15.4% | 12.7% |
75
+ | `expectation-rewritten` (warns) | 5.7% | 5.5% |
76
+
77
+ Compared inside the same repository (135 repositories with both kinds), the odds of a
78
+ blocking finding in an AI-signed commit are 0.81 times those of another commit (95% CI
79
+ 0.40-1.67): no sign that AI-signed commits weaken checks more often. Claude accounts for
80
+ 5,380 of the AI-signed commits; the other agents are too few to compare.
81
+ - **Hand-read precision.** Findings were read without knowing who wrote the commit (one
82
+ reader, an AI assistant; not independently verified). On repositories the detector had never
83
+ seen, the 11 blocking findings were 2 unexplained relaxations (TypeScript strictness switched
84
+ off in commits titled "Changes"), 7 deliberate and explained changes, and 2 false alarms. Of
85
+ 24 `assertion-removed` warnings, 1 was an unexplained weakening, 12 deliberate, 6 unclear and
86
+ 5 false alarms. So most findings are decisions a reviewer should see rather than mistakes,
87
+ and roughly one in five is a false alarm. Earlier rounds (76 findings) were used to fix the
88
+ detector and are not counted here.
89
+
90
+ Caveats. A signature shows a tool was involved, not how much, and unsigned AI help in the
91
+ "other" group would hide a difference. The repositories came from searching for those
92
+ signatures, so many are small personal projects. This measures what was committed, not what an
93
+ agent tries mid-session: a weakened check fixed before the commit is invisible. Labels are one
94
+ reader's opinion. Three small fixes (assertion helpers, throwing test-library queries,
95
+ scheduled-only workflows) were added after the labeling and have regression cases, but were not
96
+ re-labeled.
97
+
98
+ It is a **review aid**: it cannot prove who wrote a change or that code is correct, and it
99
+ reads text, not program semantics. A green result is not a quality certificate.
100
+
101
+ ### Quick start
102
+
103
+ ```bash
104
+ # Everything that is true of the code you have now
105
+ npx agent-roast check
30
106
  npx agent-roast explain swallowed-error
31
107
 
32
- # Adopt it in an existing project without blocking on all historical debt
108
+ # Adopt in an existing project without blocking on historical debt
33
109
  npx agent-roast check --write-baseline .agentroast-baseline.json
34
- # Review the findings and commit this policy file, then gate only new problems
35
110
  npx agent-roast check --baseline .agentroast-baseline.json
36
111
 
37
- # Verify precisely what you are about to commit
38
- npx agent-roast --staged
39
-
40
- # Audit pull request diffs (CI gate)
41
- npx agent-roast review --base origin/main --head HEAD --format github
42
-
43
- # Optional historical audit
112
+ # Optional: score a repository's history
44
113
  npx agent-roast --all --verbose
45
114
  ```
46
115
 
47
- A finding includes its file and line, the source snippet, and a concrete next step.
48
- To see the complete machine-readable result, use `check --format json`; terminal
49
- output shows the first 20 findings. `check --format markdown` produces a review table.
116
+ A finding includes its file and line, the evidence, and a concrete next step.
117
+ `--format json|markdown|sarif|github` are available; terminal output shows the first
118
+ 20 findings.
50
119
 
51
120
  ### Context review for pull requests
52
121
 
@@ -106,12 +175,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
106
175
  Use a separate SARIF category for introduced-findings review; it does not represent
107
176
  a full scan of all outstanding alerts.
108
177
 
109
- Install 0.8.1: `npx agent-roast@0.8.1 review --base origin/main`. For local development,
178
+ Install 0.10.0: `npx agent-roast@0.10.0 review --base origin/main`. For local development,
110
179
  run `npm run build` and use `node dist/cli.js`.
111
180
 
112
181
  ### Precise exceptions (since 0.8.0)
113
182
 
114
- Use `npx agent-roast@0.8.1 check --format json`, or build this checkout with
183
+ Use `npx agent-roast@0.10.0 check --format json`, or build this checkout with
115
184
  `npm run build` and run `node dist/cli.js`.
116
185
 
117
186
  Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
@@ -347,7 +416,7 @@ jobs:
347
416
 
348
417
  To pilot the Action without blocking on findings, set `advisory: true`. It passes
349
418
  an explicit empty fail-on list; invalid configuration, Git errors and incomplete
350
- scans still fail. The `v1` Action installs 0.8.1. The CLI equivalent is `--fail-on ''`.
419
+ scans still fail. The `v1` Action installs 0.10.0. The CLI equivalent is `--fail-on ''`.
351
420
 
352
421
  > **Security Note:** `agent-roast` runs in an isolated runner directory (`$RUNNER_TEMP`) with `--ignore-scripts` to neutralize malicious `.npmrc` files in untrusted pull requests. `pull_request_target` is not supported.
353
422
 
@@ -435,7 +504,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
435
504
  against current code. Regression tests use disposable Git repositories. The programmatic
436
505
  `auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
437
506
  use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
438
- The composite Action installs exactly 0.8.1. For local development, use
507
+ The composite Action installs exactly 0.10.0. For local development, use
439
508
  `node dist/cli.js --staged` after building.
440
509
 
441
510
  ### Author & Support
@@ -786,6 +786,36 @@ var RULE_GUIDES = {
786
786
  suggestion: "Implement the path or make its unsupported state explicit to callers. Check whether this is an intentional abstract contract.",
787
787
  example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
788
788
  },
789
+ "assertion-weakened": {
790
+ title: "Exact check replaced by a weaker one",
791
+ why: "Swapping an exact assertion for toBeDefined, a truthiness check or `is not None` keeps the test green while it verifies far less.",
792
+ suggestion: "Restore the exact check, or assert the specific value the code should produce.",
793
+ example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check)"
794
+ },
795
+ "assertion-removed": {
796
+ title: "Test checks less than before",
797
+ why: "A test that keeps its name but loses assertions verifies less behavior. Often the behavior changed on purpose; sometimes a check was dropped to get the suite green.",
798
+ suggestion: "Confirm the removed checks no longer apply, or restore them. Say so in the PR when the behavior was removed on purpose.",
799
+ example: "expect(a).toBe(1); expect(b).toBe(2) \u2192 expect(a).toBe(1) (is the second check still needed?)"
800
+ },
801
+ "test-removed": {
802
+ title: "Test removed",
803
+ why: "Deleting a failing test makes a suite green without fixing the behavior it covered. Removals are legitimate when the feature itself is gone.",
804
+ suggestion: "Confirm the behavior was intentionally removed. If it still exists, restore the test and fix the code.",
805
+ example: "it('rejects expired tokens', ...) deleted while token validation remains in src/"
806
+ },
807
+ "expectation-rewritten": {
808
+ title: "Expected value edited alongside production code",
809
+ why: "Changing an expected value while the test inputs stay the same can mean the test was adjusted to match new output instead of the output being corrected.",
810
+ suggestion: "Verify the new expectation against the requirement, not against the program's current output. Intentional behavior changes should say so in the PR.",
811
+ example: "expect(price(cart)).toBe(100) \u2192 toBe(90) with price() changed in the same PR"
812
+ },
813
+ "guard-weakened": {
814
+ title: "Quality gate made easier to pass",
815
+ why: "Lower coverage thresholds, disabled compiler strictness, downgraded lint rules, tolerated CI failures or an always-passing test script let defects through without touching the code.",
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'
818
+ },
789
819
  "panic-loop": {
790
820
  title: "Rapid overlapping fix commits",
791
821
  why: "Repeated short fix cycles can suggest a missing reproduction or feedback loop; timing alone does not prove poor work.",
@@ -806,7 +836,12 @@ var VALID_DETECTORS = [
806
836
  "panic-loop",
807
837
  "trivial-assert",
808
838
  "stub-abandonment",
809
- "test-focus"
839
+ "test-focus",
840
+ "assertion-weakened",
841
+ "assertion-removed",
842
+ "test-removed",
843
+ "expectation-rewritten",
844
+ "guard-weakened"
810
845
  ];
811
846
 
812
847
  // src/file-read.ts
@@ -839,13 +874,13 @@ function readRegularFile(filename, limit) {
839
874
  import { createHash, randomUUID as randomUUID2 } from "crypto";
840
875
  import * as fs2 from "fs";
841
876
  import path from "path";
842
- function findingFingerprint(finding) {
843
- return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
877
+ function findingFingerprint(finding2) {
878
+ return createHash("sha256").update(JSON.stringify([finding2.type, finding2.file, finding2.lineSnippet?.trim()])).digest("hex");
844
879
  }
845
880
  function createBaseline(findings) {
846
881
  const counts = /* @__PURE__ */ new Map();
847
- for (const finding of findings) {
848
- const fingerprint = findingFingerprint(finding);
882
+ for (const finding2 of findings) {
883
+ const fingerprint = findingFingerprint(finding2);
849
884
  counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
850
885
  }
851
886
  return {
@@ -877,14 +912,14 @@ function applyBaseline(findings, baseline) {
877
912
  const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
878
913
  const fresh = [];
879
914
  let baselineCount = 0;
880
- for (const finding of findings) {
881
- const fingerprint = findingFingerprint(finding);
915
+ for (const finding2 of findings) {
916
+ const fingerprint = findingFingerprint(finding2);
882
917
  const count = remaining.get(fingerprint) || 0;
883
918
  if (count > 0) {
884
919
  remaining.set(fingerprint, count - 1);
885
920
  baselineCount++;
886
921
  } else {
887
- fresh.push(finding);
922
+ fresh.push(finding2);
888
923
  }
889
924
  }
890
925
  return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
@@ -933,11 +968,11 @@ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Da
933
968
  const occurrences = new Map(priorOccurrences);
934
969
  const actionable = [];
935
970
  const suppressedFindings = [];
936
- for (const finding of findings) {
937
- const fingerprint = findingFingerprint(finding);
971
+ for (const finding2 of findings) {
972
+ const fingerprint = findingFingerprint(finding2);
938
973
  const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
939
974
  occurrences.set(fingerprint, occurrence);
940
- const enriched = { ...finding, fingerprint, occurrence };
975
+ const enriched = { ...finding2, fingerprint, occurrence };
941
976
  const allowance = active.get(fingerprint);
942
977
  if (allowance && allowance.remaining > 0) {
943
978
  allowance.remaining--;
@@ -950,7 +985,7 @@ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Da
950
985
  // src/config.ts
951
986
  var MAX_CONFIG_BYTES = 64 * 1024;
952
987
  var DEFAULT_CONFIG = {
953
- failOn: ["test-skip", "trivial-assert", "test-focus"],
988
+ failOn: ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"],
954
989
  ignorePaths: [
955
990
  "**/fixtures/**",
956
991
  "**/__fixtures__/**",
@@ -966,6 +1001,11 @@ var DEFAULT_CONFIG = {
966
1001
  "test-skip": "error",
967
1002
  "trivial-assert": "error",
968
1003
  "test-focus": "error",
1004
+ "assertion-weakened": "error",
1005
+ "assertion-removed": "warn",
1006
+ "guard-weakened": "error",
1007
+ "test-removed": "warn",
1008
+ "expectation-rewritten": "warn",
969
1009
  "swallowed-error": "warn",
970
1010
  "stub-abandonment": "warn",
971
1011
  "type-escape": "warn"
@@ -1395,7 +1435,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1395
1435
  });
1396
1436
  }
1397
1437
  const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
1398
- for (const finding of ordered) infractions.push(finding);
1438
+ for (const finding2 of ordered) infractions.push(finding2);
1399
1439
  }
1400
1440
  return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
1401
1441
  }
@@ -1530,7 +1570,7 @@ var MetricsCollector = class {
1530
1570
  const counts = {};
1531
1571
  for (const infraction of audit.infractions) {
1532
1572
  const count = counts[infraction.type] || 0;
1533
- if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type].push(infraction);
1573
+ if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type]?.push(infraction);
1534
1574
  counts[infraction.type] = count + 1;
1535
1575
  }
1536
1576
  }
@@ -1976,8 +2016,8 @@ function exceptionTerminal(result) {
1976
2016
  const accepted = result.suppressedFindings || [];
1977
2017
  if (accepted.length) {
1978
2018
  lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
1979
- for (const finding of accepted.slice(0, 20)) lines.push(
1980
- ` ACCEPTED [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber} \u2014 ${terminalText(finding.suppression.reason)} (expires ${terminalText(finding.suppression.expires)})`
2019
+ for (const finding2 of accepted.slice(0, 20)) lines.push(
2020
+ ` ACCEPTED [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber} \u2014 ${terminalText(finding2.suppression.reason)} (expires ${terminalText(finding2.suppression.expires)})`
1981
2021
  );
1982
2022
  if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
1983
2023
  }
@@ -1997,7 +2037,7 @@ function exceptionMarkdown(result) {
1997
2037
  "| Rule | Location | Reason | Expires |",
1998
2038
  "| --- | --- | --- | --- |"
1999
2039
  );
2000
- for (const finding of accepted) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.suppression.reason)} | ${markdownCell(finding.suppression.expires)} |`);
2040
+ for (const finding2 of accepted) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.suppression.reason)} | ${markdownCell(finding2.suppression.expires)} |`);
2001
2041
  }
2002
2042
  if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
2003
2043
  return lines;
@@ -2054,12 +2094,12 @@ function formatReviewTerminal(result) {
2054
2094
  lines.push(...exceptionTerminal(result));
2055
2095
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
2056
2096
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
2057
- for (const finding of result.infractions.slice(0, 20)) {
2097
+ for (const finding2 of result.infractions.slice(0, 20)) {
2058
2098
  lines.push(
2059
2099
  "",
2060
- ` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
2061
- ` ${terminalText(finding.lineSnippet || finding.reason)}`,
2062
- ` Next: ${terminalText(finding.suggestion || "Review this occurrence.")}`
2100
+ ` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
2101
+ ` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
2102
+ ` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
2063
2103
  );
2064
2104
  }
2065
2105
  if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
@@ -2080,7 +2120,7 @@ function formatReviewMarkdown(result) {
2080
2120
  ];
2081
2121
  if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
2082
2122
  lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
2083
- for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
2123
+ for (const finding2 of result.infractions) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} | ${markdownCell(finding2.suggestion || "Review this occurrence.")} |`);
2084
2124
  lines.push(...exceptionMarkdown(result));
2085
2125
  if (result.removedFindings.length) {
2086
2126
  lines.push(
@@ -2092,7 +2132,7 @@ function formatReviewMarkdown(result) {
2092
2132
  "| Rule | Previous location | Previous evidence |",
2093
2133
  "| --- | --- | --- |"
2094
2134
  );
2095
- for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
2135
+ for (const finding2 of result.removedFindings) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} |`);
2096
2136
  }
2097
2137
  for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
2098
2138
  lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
@@ -2338,26 +2378,26 @@ var version = require2("../package.json").version;
2338
2378
  function formatSarif(report) {
2339
2379
  const occurrences = /* @__PURE__ */ new Map();
2340
2380
  const allFindings = [
2341
- ...report.infractions.map((finding) => ({ finding, suppression: void 0 })),
2342
- ...(report.suppressedFindings ?? []).map((finding) => ({ finding, suppression: finding.suppression }))
2381
+ ...report.infractions.map((finding2) => ({ finding: finding2, suppression: void 0 })),
2382
+ ...(report.suppressedFindings ?? []).map((finding2) => ({ finding: finding2, suppression: finding2.suppression }))
2343
2383
  ];
2344
2384
  allFindings.sort((a, b) => (a.finding.file ?? "").localeCompare(b.finding.file ?? "") || (a.finding.lineNumber ?? 1) - (b.finding.lineNumber ?? 1) || a.finding.type.localeCompare(b.finding.type));
2345
- const results = allFindings.map(({ finding, suppression }) => {
2346
- const filename = finding.file;
2385
+ const results = allFindings.map(({ finding: finding2, suppression }) => {
2386
+ const filename = finding2.file;
2347
2387
  if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
2348
2388
  throw new ConfigError("SARIF needs repository-relative finding paths.");
2349
2389
  }
2350
- const fingerprint = findingFingerprint(finding);
2351
- const occurrence = finding.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
2390
+ const fingerprint = findingFingerprint(finding2);
2391
+ const occurrence = finding2.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
2352
2392
  occurrences.set(fingerprint, occurrence);
2353
2393
  return {
2354
- ruleId: finding.type,
2355
- ruleIndex: VALID_DETECTORS.indexOf(finding.type),
2356
- level: report.failedRules.includes(finding.type) ? "error" : "warning",
2357
- message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
2394
+ ruleId: finding2.type,
2395
+ ruleIndex: VALID_DETECTORS.indexOf(finding2.type),
2396
+ level: report.failedRules.includes(finding2.type) ? "error" : "warning",
2397
+ message: { text: `${finding2.reason}. ${finding2.suggestion || RULE_GUIDES[finding2.type].suggestion}` },
2358
2398
  locations: [{ physicalLocation: {
2359
2399
  artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
2360
- region: { startLine: Math.max(1, finding.lineNumber || 1) }
2400
+ region: { startLine: Math.max(1, finding2.lineNumber || 1) }
2361
2401
  } }],
2362
2402
  partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
2363
2403
  ...suppression ? { suppressions: [{
@@ -2662,7 +2702,7 @@ async function checkProject(options = {}) {
2662
2702
  const cwd = path5.resolve(options.cwd || process.cwd());
2663
2703
  const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
2664
2704
  const config = loadConfig(root);
2665
- const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2705
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
2666
2706
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
2667
2707
  const format = options.format || "terminal";
2668
2708
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
@@ -2699,14 +2739,14 @@ async function checkProject(options = {}) {
2699
2739
  const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
2700
2740
  const notInBaseline = new Set(applied.findings);
2701
2741
  const acceptedCounts = /* @__PURE__ */ new Map();
2702
- for (const finding of infractions) {
2703
- if (notInBaseline.has(finding)) continue;
2704
- const fingerprint = findingFingerprint(finding);
2742
+ for (const finding2 of infractions) {
2743
+ if (notInBaseline.has(finding2)) continue;
2744
+ const fingerprint = findingFingerprint(finding2);
2705
2745
  acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
2706
2746
  }
2707
2747
  const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
2708
2748
  const findings = exceptions.findings;
2709
- const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2749
+ const failedRules = [...new Set(findings.filter((finding2) => failOn.includes(finding2.type)).map((finding2) => finding2.type))];
2710
2750
  const complete = skippedFiles.length === 0;
2711
2751
  const passed = complete && failedRules.length === 0;
2712
2752
  const result = {
@@ -2738,24 +2778,437 @@ async function checkProject(options = {}) {
2738
2778
 
2739
2779
  // src/review.ts
2740
2780
  import path6 from "path";
2781
+
2782
+ // src/tampering.ts
2783
+ import { createHash as createHash3 } from "crypto";
2784
+ var EMPTY = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
2785
+ function languageOf(file) {
2786
+ if (/\.(?:[cm]?[jt]sx?)$/i.test(file)) return "js";
2787
+ if (/\.py$/i.test(file)) return "py";
2788
+ if (/_test\.go$/i.test(file)) return "go";
2789
+ return null;
2790
+ }
2791
+ function codeLines(text, language) {
2792
+ let block = false;
2793
+ return text.split(/\r?\n/).map((line) => {
2794
+ const trimmed = line.trim();
2795
+ if (language !== "py") {
2796
+ if (block) {
2797
+ if (trimmed.includes("*/")) block = false;
2798
+ return "";
2799
+ }
2800
+ if (trimmed.startsWith("/*")) {
2801
+ if (!trimmed.includes("*/")) block = true;
2802
+ return "";
2803
+ }
2804
+ if (trimmed.startsWith("//")) return "";
2805
+ } else if (trimmed.startsWith("#")) return "";
2806
+ return line;
2807
+ });
2808
+ }
2809
+ var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|serial))*\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/;
2810
+ var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
2811
+ var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
2812
+ var JS_ASSERTION = /(?<![\w$.])(?:expect|assert|expectTypeOf)\b(?!\s*\.\s*(?:assertions|hasAssertions|extend|anything|any|objectContaining|arrayContaining|stringContaining|stringMatching)\b)(?:\s*\.\s*\w+)*\s*\(|\.should\b/;
2813
+ var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
2814
+ var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
2815
+ function callEnd(text, from, limit) {
2816
+ let depth = 1, quote = "";
2817
+ for (let i = from; i < limit; i++) {
2818
+ const char = text[i];
2819
+ if (quote) {
2820
+ if (char === "\\") i++;
2821
+ else if (char === quote || char === "\n" && quote !== "`") quote = "";
2822
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
2823
+ else if (char === "/" && text[i + 1] === "/" && (i === from || /\s/.test(text[i - 1]))) {
2824
+ const newline = text.indexOf("\n", i);
2825
+ i = newline < 0 || newline > limit ? limit : newline;
2826
+ } else if (char === "(") depth++;
2827
+ else if (char === ")" && --depth === 0) return i;
2828
+ }
2829
+ return limit;
2830
+ }
2831
+ function testBodies(text, language) {
2832
+ const lines = codeLines(text, language);
2833
+ if (language === "js") {
2834
+ const code = lines.join("\n");
2835
+ const matches = [...code.matchAll(new RegExp(JS_TEST_START.source, "g"))];
2836
+ let line = 1, cursor = 0;
2837
+ return matches.map((match, i) => {
2838
+ const at = match.index ?? 0;
2839
+ for (; cursor < at; cursor++) if (code.charCodeAt(cursor) === 10) line++;
2840
+ const from = at + match[0].length;
2841
+ const limit = i + 1 < matches.length ? matches[i + 1].index ?? code.length : code.length;
2842
+ return { name: match[2], line, body: code.slice(from, Math.min(callEnd(code, from, limit) + 1, limit)), parametrized: false };
2843
+ });
2844
+ }
2845
+ const startPattern = language === "py" ? PY_TEST_START : GO_TEST_START;
2846
+ const starts = [];
2847
+ lines.forEach((line, index) => {
2848
+ const match = startPattern.exec(line);
2849
+ if (match) starts.push({ index, name: match[1], length: match[0].length });
2850
+ });
2851
+ const indentOf = (line) => /^\s*/.exec(line)[0].length;
2852
+ return starts.map((start, n) => {
2853
+ const limit = n + 1 < starts.length ? starts[n + 1].index : lines.length;
2854
+ const header = lines[start.index], indent = indentOf(header);
2855
+ let end = start.index + 1, depth = balance(header);
2856
+ while (depth > 0 && end < limit) depth += balance(lines[end++]);
2857
+ while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() || indentOf(lines[end]) > indent)) end++;
2858
+ let top = start.index;
2859
+ while (top > 0 && start.index - top < 200 && lines[top - 1].trim() && !/^\s*(?:async\s+)?(?:def|class)\b/.test(lines[top - 1])) top--;
2860
+ return {
2861
+ name: start.name,
2862
+ line: start.index + 1,
2863
+ body: [header.slice(start.length), ...lines.slice(start.index + 1, end)].join("\n"),
2864
+ parametrized: /parametrize/.test(lines.slice(top, start.index).join("\n"))
2865
+ };
2866
+ });
2867
+ }
2868
+ function balance(line) {
2869
+ const bare = line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "");
2870
+ let net = 0;
2871
+ for (const char of bare) net += "([{".includes(char) ? 1 : ")]}".includes(char) ? -1 : 0;
2872
+ return net;
2873
+ }
2874
+ function normalize(statement) {
2875
+ return statement.replace(/\s+/g, "").replace(/'/g, '"').replace(/,([)\]}])/g, "$1").replace(/;$/, "");
2876
+ }
2877
+ function splitStatements(text) {
2878
+ const pieces = [];
2879
+ let quote = "", start = 0;
2880
+ for (let i = 0; i < text.length; i++) {
2881
+ const char = text[i];
2882
+ if (quote) {
2883
+ if (char === "\\") i++;
2884
+ else if (char === quote) quote = "";
2885
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
2886
+ else if (char === ";") {
2887
+ pieces.push(text.slice(start, i));
2888
+ start = i + 1;
2889
+ }
2890
+ }
2891
+ pieces.push(text.slice(start));
2892
+ return pieces.map((piece) => piece.trim()).filter(Boolean);
2893
+ }
2894
+ function stripTrailingComment(line, language) {
2895
+ const marker = language === "py" ? "#" : "//";
2896
+ let quote = "";
2897
+ for (let i = 0; i < line.length; i++) {
2898
+ const char = line[i];
2899
+ if (quote) {
2900
+ if (char === "\\") i++;
2901
+ else if (char === quote) quote = "";
2902
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
2903
+ else if (line.startsWith(marker, i) && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
2904
+ }
2905
+ return line;
2906
+ }
2907
+ var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
2908
+ function parseTests(text, language) {
2909
+ const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
2910
+ const seen = /* @__PURE__ */ new Map();
2911
+ return testBodies(text, language).map((test) => {
2912
+ const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
2913
+ const assertions = [], raw = [], setupLines = [];
2914
+ for (let i = 0; i < body.length; i++) {
2915
+ if (!assertionPattern.test(body[i])) {
2916
+ setupLines.push(body[i]);
2917
+ continue;
2918
+ }
2919
+ let statement = body[i].trim(), depth = balance(body[i]);
2920
+ while (depth > 0 && i + 1 < body.length) {
2921
+ i++;
2922
+ statement += " " + body[i].trim();
2923
+ depth += balance(body[i]);
2924
+ }
2925
+ for (const piece of language === "js" ? splitStatements(statement) : [statement]) {
2926
+ const clean = piece.replace(CALLBACK_HEADER, "");
2927
+ if (assertionPattern.test(clean)) {
2928
+ raw.push(clean);
2929
+ assertions.push(normalize(clean));
2930
+ } else setupLines.push(clean);
2931
+ }
2932
+ }
2933
+ const setup = setupLines.join("\n").replace(/\s+/g, "");
2934
+ const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
2935
+ const count = (seen.get(test.name) ?? 0) + 1;
2936
+ seen.set(test.name, count);
2937
+ return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
2938
+ });
2939
+ }
2940
+ function skeleton(assertion) {
2941
+ 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, "");
2942
+ }
2943
+ var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)/;
2944
+ var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
2945
+ var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
2946
+ var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
2947
+ var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
2948
+ function isWeaker(oldAssertion, newAssertion, language) {
2949
+ if (language === "js") return !THROWING_QUERY.test(newAssertion) && WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion) && JS_SPECIFIC.test(oldAssertion);
2950
+ if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion) && PY_SPECIFIC.test(oldAssertion);
2951
+ return false;
2952
+ }
2953
+ 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+)*$/;
2954
+ function literalTokens(statement) {
2955
+ const tokens = /* @__PURE__ */ new Set();
2956
+ const literal = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1|(?<=[(,]\s*)\/((?:\\.|[^/\\\n])+)\/[a-z]*/g;
2957
+ for (const match of statement.matchAll(literal)) {
2958
+ for (const token of (match[2] ?? match[3] ?? "").match(/[A-Za-z0-9_:.=@#%-]{4,}/g) ?? []) {
2959
+ if (!GENERIC_ARGUMENT.test(token)) tokens.add(token);
2960
+ }
2961
+ }
2962
+ return [...tokens];
2963
+ }
2964
+ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0,2}(?:assert|check|verify|expect|require)_\w+)\s*\(/g;
2965
+ function finding(type, file, line, snippet, reason, commit) {
2966
+ return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
2967
+ }
2968
+ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
2969
+ const language = languageOf(newText === null ? oldPath : newPath);
2970
+ if (!language || !(isTestFile(newPath) || isTestFile(oldPath))) return EMPTY;
2971
+ const before = oldText === null ? [] : parseTests(oldText, language);
2972
+ const after = newText === null ? [] : parseTests(newText, language);
2973
+ const afterByKey = new Map(after.map((test) => [test.key, test]));
2974
+ const newCode = newText === null ? "" : codeLines(newText, language).join("\n");
2975
+ const tally = (tests) => {
2976
+ const totals = /* @__PURE__ */ new Map();
2977
+ for (const test of tests) for (const assertion of test.assertions) totals.set(assertion, (totals.get(assertion) ?? 0) + 1);
2978
+ return totals;
2979
+ };
2980
+ const oldTotals = tally(before), newTotals = tally(after);
2981
+ const beforeKeys = new Set(before.map((test) => test.key));
2982
+ const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
2983
+ const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
2984
+ for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
2985
+ for (const old of before) {
2986
+ const current = afterByKey.get(old.key);
2987
+ if (!current) {
2988
+ if (newCode && ['"', "'", "`"].some((quote) => newCode.includes(quote + old.name + quote))) continue;
2989
+ result.removedTests.push({
2990
+ bodyHash: old.bodyHash,
2991
+ finding: finding(
2992
+ "test-removed",
2993
+ newPath,
2994
+ 1,
2995
+ `${old.name}: test no longer present`,
2996
+ newText === null ? "Test file deleted" : "Test removed",
2997
+ commit
2998
+ )
2999
+ });
3000
+ continue;
3001
+ }
3002
+ if (current.parametrized && !old.parametrized) continue;
3003
+ const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
3004
+ if (snapshot(current) && !snapshot(old)) continue;
3005
+ if (current.assertions.length < old.assertions.length) {
3006
+ if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
3007
+ const budget = tally([current]);
3008
+ const lost = [];
3009
+ old.assertions.forEach((assertion, i) => {
3010
+ const left = budget.get(assertion) ?? 0;
3011
+ if (left > 0) budget.set(assertion, left - 1);
3012
+ else lost.push({ normalized: assertion, raw: old.raw[i] });
3013
+ });
3014
+ const introduced = `${current.text}
3015
+ ${addedText}`;
3016
+ const accounted = (item) => {
3017
+ if ((newTotals.get(item.normalized) ?? 0) >= (oldTotals.get(item.normalized) ?? 0)) return true;
3018
+ const tokens = literalTokens(item.raw);
3019
+ return tokens.length > 0 && tokens.every((token) => introduced.includes(token));
3020
+ };
3021
+ if (lost.every(accounted)) continue;
3022
+ result.immediate.push(finding(
3023
+ "assertion-removed",
3024
+ newPath,
3025
+ current.line,
3026
+ `${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
3027
+ "Test checks less than before",
3028
+ commit
3029
+ ));
3030
+ continue;
3031
+ }
3032
+ if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
3033
+ const changed = old.assertions.map((assertion, i) => ({ before: assertion, after: current.assertions[i], rawBefore: old.raw[i], rawAfter: current.raw[i] })).filter((pair) => pair.before !== pair.after);
3034
+ const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
3035
+ if (weaker) {
3036
+ result.immediate.push(finding(
3037
+ "assertion-weakened",
3038
+ newPath,
3039
+ current.line,
3040
+ `${old.name}: ${weaker.rawBefore.slice(0, 80)} \u2192 ${weaker.rawAfter.slice(0, 80)}`,
3041
+ "Assertion replaced by a weaker check",
3042
+ commit
3043
+ ));
3044
+ } else if (changed.length && old.setup === current.setup && changed.every((pair) => skeleton(pair.before) === skeleton(pair.after))) {
3045
+ result.rewritten.push(finding(
3046
+ "expectation-rewritten",
3047
+ newPath,
3048
+ current.line,
3049
+ `${old.name}: ${changed[0].rawBefore.slice(0, 80)} \u2192 ${changed[0].rawAfter.slice(0, 80)}`,
3050
+ "Expected value edited while the test inputs stayed the same",
3051
+ commit
3052
+ ));
3053
+ }
3054
+ }
3055
+ }
3056
+ return result;
3057
+ }
3058
+ function resolveTampering(files, productionChanged) {
3059
+ const added = new Set(files.flatMap((file) => file.addedHashes));
3060
+ return [
3061
+ ...files.flatMap((file) => file.immediate),
3062
+ ...files.flatMap((file) => file.removedTests).filter((removed) => !added.has(removed.bodyHash)).map((removed) => removed.finding),
3063
+ ...productionChanged ? files.flatMap((file) => file.rewritten) : []
3064
+ ];
3065
+ }
3066
+ 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|\.coveragerc|codecov\.ya?ml|\.gitlab-ci\.ya?ml|\.github\/workflows\/[^/]+\.ya?ml)$/i;
3067
+ function isGuardFile(file) {
3068
+ return GUARD_FILE.test(file);
3069
+ }
3070
+ function lineOf(text, index) {
3071
+ let line = 1;
3072
+ for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
3073
+ return line;
3074
+ }
3075
+ function numbers(text, pattern) {
3076
+ const seen = /* @__PURE__ */ new Map();
3077
+ return [...text.matchAll(pattern)].map((match) => {
3078
+ const label = match[1] ?? "value";
3079
+ const count = (seen.get(label) ?? 0) + 1;
3080
+ seen.set(label, count);
3081
+ return { key: `${label}#${count}`, value: Number(match[match.length - 1]), index: match.index ?? 0 };
3082
+ });
3083
+ }
3084
+ var THRESHOLD_PATTERNS = [
3085
+ /\b(lines|branches|functions|statements)\b["']?\s*[:=]\s*(\d+(?:\.\d+)?)/g,
3086
+ /\b(fail_under)\s*[:=]\s*(\d+(?:\.\d+)?)/g,
3087
+ /(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
3088
+ ];
3089
+ var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
3090
+ var CI_RUNNER = new RegExp(
3091
+ 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/.-])`
3092
+ );
3093
+ var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
3094
+ var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
3095
+ var CI_GATE = /^\s*(?:pull_request|pull_request_target|push|merge_group)\s*:|^\s*on\s*:\s*(?:\[[^\]]*\b(?:push|pull_request|pull_request_target|merge_group)\b[^\]]*\]|(?:push|pull_request|pull_request_target|merge_group)\s*$)|^\s*-\s*(?:push|pull_request|pull_request_target|merge_group)\s*$/m;
3096
+ function gatingTestLines(file, text) {
3097
+ return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text, CI_RUNNER) : 0;
3098
+ }
3099
+ function matchingLines(text, pattern) {
3100
+ return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
3101
+ }
3102
+ function ciTestSteps(file, oldText, newText) {
3103
+ if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
3104
+ const was = oldText ?? "", now = newText ?? "";
3105
+ const after = gatingTestLines(file, now);
3106
+ return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, CI_RUNNER) : 0 };
3107
+ }
3108
+ function resolveCiTestSteps(counts, commit) {
3109
+ const before = counts.reduce((sum, item) => sum + item.before, 0);
3110
+ const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
3111
+ if (after >= before) return [];
3112
+ 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));
3113
+ }
3114
+ var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true/;
3115
+ function lenientSteps(text) {
3116
+ const lines = text.split(/\r?\n/);
3117
+ const indentOf = (line) => line.length - line.trimStart().length;
3118
+ const found = /* @__PURE__ */ new Map();
3119
+ lines.forEach((line, i) => {
3120
+ if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
3121
+ let j = i - 1;
3122
+ while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
3123
+ const identity = j >= 0 ? lines[j].trim() : "";
3124
+ const entry = found.get(identity);
3125
+ if (entry) entry.count++;
3126
+ else found.set(identity, { count: 1, line: i + 1 });
3127
+ });
3128
+ return found;
3129
+ }
3130
+ function severityOf(value) {
3131
+ return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
3132
+ }
3133
+ function analyzeGuardFile(file, oldText, newText, commit) {
3134
+ if (oldText === null || newText === null) return [];
3135
+ const out = [];
3136
+ const add = (index, snippet, reason) => out.push(finding("guard-weakened", file, lineOf(newText, index), snippet, reason, commit));
3137
+ if (/threshold|fail_under|cov-fail-under|coverage/i.test(newText)) {
3138
+ for (const pattern of THRESHOLD_PATTERNS) {
3139
+ const old = new Map(numbers(oldText, pattern).map((item) => [item.key, item.value]));
3140
+ for (const item of numbers(newText, pattern)) {
3141
+ const previous = old.get(item.key);
3142
+ if (previous !== void 0 && item.value < previous) {
3143
+ add(item.index, `${item.key.split("#")[0]}: ${previous} \u2192 ${item.value}`, "Coverage threshold lowered");
3144
+ }
3145
+ }
3146
+ }
3147
+ }
3148
+ if (/(?:^|\/)(?:tsconfig|jsconfig)[^/]*\.json$/i.test(file)) {
3149
+ for (const name of SWITCHES) {
3150
+ const pattern = new RegExp(`"${name}"\\s*:\\s*(true|false)`);
3151
+ const before = pattern.exec(oldText), after = pattern.exec(newText);
3152
+ if (before?.[1] === "true" && after?.[1] === "false") add(after.index, `${name}: true \u2192 false`, "Compiler strictness switched off");
3153
+ }
3154
+ }
3155
+ if (/eslint/i.test(file)) {
3156
+ const rules = (text) => {
3157
+ const found = /* @__PURE__ */ new Map();
3158
+ for (const match of text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)) {
3159
+ const level = severityOf(match[2]);
3160
+ const known = found.get(match[1]);
3161
+ if (!known || level > known.level) found.set(match[1], { level, index: match.index ?? 0 });
3162
+ }
3163
+ return found;
3164
+ };
3165
+ const before = rules(oldText), after = rules(newText);
3166
+ for (const [name, now] of after) {
3167
+ const previous = before.get(name);
3168
+ const replaced = [...after].some(([other, setting]) => other !== name && other.endsWith(`/${name}`) && setting.level === 2);
3169
+ if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
3170
+ }
3171
+ }
3172
+ if (CI_FILE.test(file)) {
3173
+ const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).map((line) => line.trim()));
3174
+ for (const [identity, now] of lenientSteps(newText)) {
3175
+ if (identity && existing.has(identity) && now.count > (before.get(identity)?.count ?? 0)) {
3176
+ out.push(finding("guard-weakened", file, now.line, "continue-on-error: true", "CI step is now allowed to fail", commit));
3177
+ break;
3178
+ }
3179
+ }
3180
+ }
3181
+ if (/(?:^|\/)package\.json$/i.test(file)) {
3182
+ try {
3183
+ const before = JSON.parse(oldText)?.scripts?.test, after = JSON.parse(newText)?.scripts?.test;
3184
+ if (typeof before === "string" && typeof after === "string" && before !== after && (/^\s*(?:echo\b|true\b|exit\s+0|:)/.test(after) || /\|\|\s*(?:true|exit\s+0)\b/.test(after) || /--passWithNoTests/.test(after) && !/--passWithNoTests/.test(before))) {
3185
+ add(Math.max(0, newText.indexOf('"test"')), `test: ${after}`, "Test script can no longer fail");
3186
+ }
3187
+ } catch {
3188
+ }
3189
+ }
3190
+ return out;
3191
+ }
3192
+
3193
+ // src/review.ts
2741
3194
  var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2742
3195
  var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
2743
3196
  function compareFindings(before, after, newPath) {
2744
3197
  const old = /* @__PURE__ */ new Map();
2745
- for (const finding of before) {
2746
- const key = findingFingerprint({ ...finding, file: newPath });
3198
+ for (const finding2 of before) {
3199
+ const key = findingFingerprint({ ...finding2, file: newPath });
2747
3200
  const bucket = old.get(key) || { findings: [], matched: 0 };
2748
- bucket.findings.push(finding);
3201
+ bucket.findings.push(finding2);
2749
3202
  old.set(key, bucket);
2750
3203
  }
2751
3204
  const introduced = [];
2752
3205
  let unchanged = 0;
2753
- for (const finding of after) {
2754
- const bucket = old.get(findingFingerprint(finding));
3206
+ for (const finding2 of after) {
3207
+ const bucket = old.get(findingFingerprint(finding2));
2755
3208
  if (bucket && bucket.matched < bucket.findings.length) {
2756
3209
  bucket.matched++;
2757
3210
  unchanged++;
2758
- } else introduced.push(finding);
3211
+ } else introduced.push(finding2);
2759
3212
  }
2760
3213
  return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
2761
3214
  }
@@ -2773,7 +3226,7 @@ async function compareSourceChanges(options) {
2773
3226
  const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
2774
3227
  const format = options.format ?? "terminal";
2775
3228
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
2776
- const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
3229
+ const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
2777
3230
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
2778
3231
  const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
2779
3232
  const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
@@ -2805,13 +3258,42 @@ async function compareSourceChanges(options) {
2805
3258
  output: ""
2806
3259
  };
2807
3260
  let totalBytes = 0;
3261
+ const tamperingFiles = [];
3262
+ const guardFindings = [];
3263
+ const ciCounts = [];
3264
+ let productionChanged = false;
3265
+ const readText = async (mode, oid) => {
3266
+ if (mode === "000000") return null;
3267
+ if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
3268
+ const content = await readBlob(cwd, oid, options.signal);
3269
+ totalBytes += content.length;
3270
+ if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
3271
+ if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
3272
+ return new TextDecoder("utf-8", { fatal: true }).decode(content);
3273
+ };
2808
3274
  const existingCounts = /* @__PURE__ */ new Map();
2809
3275
  for (const change of changes) {
2810
3276
  assertNotAborted(options.signal);
2811
3277
  const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
2812
3278
  const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
2813
3279
  if (!oldSupported && !newSupported) {
2814
- result.unsupportedFiles++;
3280
+ const guarded = (isGuardFile(change.newPath) || isGuardFile(change.oldPath)) && isCodeFile(change.newPath + ".ts", ignores);
3281
+ if (!guarded) {
3282
+ result.unsupportedFiles++;
3283
+ continue;
3284
+ }
3285
+ try {
3286
+ const oldText = await readText(change.oldMode, change.oldOid);
3287
+ const newText = await readText(change.newMode, change.newOid);
3288
+ guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
3289
+ const ci = ciTestSteps(change.newPath, oldText, newText);
3290
+ if (ci) ciCounts.push(ci);
3291
+ result.filesInspected++;
3292
+ } catch (error) {
3293
+ assertNotAborted(options.signal);
3294
+ result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
3295
+ if (totalBytes > MAX_SCAN_BYTES2) break;
3296
+ }
2815
3297
  continue;
2816
3298
  }
2817
3299
  const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
@@ -2822,14 +3304,8 @@ async function compareSourceChanges(options) {
2822
3304
  }
2823
3305
  try {
2824
3306
  let inspectedLines = 0;
2825
- const scan = async (mode, oid, filename, commit) => {
2826
- if (mode === "000000") return [];
2827
- if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
2828
- const content = await readBlob(cwd, oid, options.signal);
2829
- totalBytes += content.length;
2830
- if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
2831
- if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
2832
- const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
3307
+ const scan = (text, filename, commit) => {
3308
+ if (text === null) return [];
2833
3309
  const lines = text.split(/\r?\n/);
2834
3310
  if (lines.at(-1) === "") lines.pop();
2835
3311
  inspectedLines += lines.length;
@@ -2838,13 +3314,18 @@ async function compareSourceChanges(options) {
2838
3314
  if (oldSupported && !newSupported && change.newMode !== "000000") {
2839
3315
  throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
2840
3316
  }
2841
- const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
2842
- const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
3317
+ const oldText = await readText(change.oldMode, change.oldOid);
3318
+ const newText = await readText(change.newMode, change.newOid);
3319
+ const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
3320
+ const after = scan(newText, change.newPath, refs.headSha);
3321
+ if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
3322
+ else if (change.oldOid !== change.newOid) productionChanged = true;
3323
+ if ((isGuardFile(change.newPath) || isGuardFile(change.oldPath)) && oldText !== null && newText !== null) guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
2843
3324
  const compared = compareFindings(before, after, change.newPath);
2844
3325
  const introduced = new Set(compared.introduced);
2845
- for (const finding of after) {
2846
- if (introduced.has(finding)) continue;
2847
- const fingerprint = findingFingerprint(finding);
3326
+ for (const finding2 of after) {
3327
+ if (introduced.has(finding2)) continue;
3328
+ const fingerprint = findingFingerprint(finding2);
2848
3329
  existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
2849
3330
  }
2850
3331
  result.infractions.push(...compared.introduced);
@@ -2863,11 +3344,13 @@ async function compareSourceChanges(options) {
2863
3344
  assertNotAborted(options.signal);
2864
3345
  await staged?.verify();
2865
3346
  result.complete = result.skippedFiles.length === 0;
3347
+ const rules = policy.config.rules ?? {};
3348
+ result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
2866
3349
  const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
2867
3350
  result.infractions = exceptions.findings;
2868
3351
  result.suppressedFindings = exceptions.suppressedFindings;
2869
3352
  result.expiredExceptionCount = exceptions.expiredExceptionCount;
2870
- result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
3353
+ result.failedRules = [...new Set(result.infractions.filter((finding2) => failOn.includes(finding2.type)).map((finding2) => finding2.type))];
2871
3354
  result.passed = result.complete && result.failedRules.length === 0;
2872
3355
  result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
2873
3356
  if (format === "json") result.output = JSON.stringify(result, null, 2);
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  roastRepository,
23
23
  terminalText,
24
24
  writeBaseline
25
- } from "./chunk-U7OCVA3D.js";
25
+ } from "./chunk-NSGANOZ5.js";
26
26
 
27
27
  // src/cli.ts
28
28
  import { Command } from "commander";
package/dist/index.d.ts CHANGED
@@ -32,7 +32,7 @@ interface CommitInfo {
32
32
  isAiAttributed: boolean;
33
33
  aiSignatures: string[];
34
34
  }
35
- type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus";
35
+ type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus" | "assertion-weakened" | "assertion-removed" | "test-removed" | "expectation-rewritten" | "guard-weakened";
36
36
  declare const VALID_DETECTORS: readonly DetectorType[];
37
37
  interface Infraction {
38
38
  fingerprint?: string;
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  unquoteGitPath,
64
64
  validateExceptions,
65
65
  writeBaseline
66
- } from "./chunk-U7OCVA3D.js";
66
+ } from "./chunk-NSGANOZ5.js";
67
67
  export {
68
68
  CONFIG_FILENAMES,
69
69
  ConfigError,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.8.1",
4
- "description": "Offline code checks for skipped tests, swallowed errors, unfinished stubs and AI coding shortcuts",
3
+ "version": "0.10.0",
4
+ "description": "Offline review for AI-assisted changes: catches weakened tests, lowered quality gates, skipped tests and swallowed errors",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -20,16 +20,22 @@
20
20
  "test": "vitest run",
21
21
  "prepublishOnly": "npm run build",
22
22
  "pretest": "npm run build",
23
- "typecheck": "tsc --noEmit"
23
+ "typecheck": "tsc --noEmit",
24
+ "bench": "node scripts/benchmark-recall.mjs",
25
+ "bench:noise": "node scripts/benchmark-noise.mjs"
24
26
  },
25
27
  "keywords": [
26
28
  "ai",
27
29
  "agents",
28
- "cursor",
30
+ "ai-agents",
29
31
  "claude-code",
30
- "roast",
32
+ "cursor",
33
+ "codex",
34
+ "code-review",
35
+ "test-quality",
36
+ "ci",
37
+ "sarif",
31
38
  "git",
32
- "code-quality",
33
39
  "cli"
34
40
  ],
35
41
  "author": "Nari-Ab",
@@ -47,9 +53,7 @@
47
53
  "picocolors": "^1.1.0"
48
54
  },
49
55
  "devDependencies": {
50
- "@napi-rs/canvas": "^1.0.10",
51
56
  "@types/node": "^20.0.0",
52
- "gifenc": "^1.0.3",
53
57
  "tsup": "^8.0.0",
54
58
  "typescript": "^5.0.0",
55
59
  "vitest": "^4.1.12"
package/schema.json CHANGED
@@ -20,7 +20,12 @@
20
20
  "panic-loop",
21
21
  "trivial-assert",
22
22
  "stub-abandonment",
23
- "test-focus"
23
+ "test-focus",
24
+ "assertion-weakened",
25
+ "assertion-removed",
26
+ "test-removed",
27
+ "expectation-rewritten",
28
+ "guard-weakened"
24
29
  ]
25
30
  }
26
31
  },
@@ -94,6 +99,41 @@
94
99
  "warn",
95
100
  "off"
96
101
  ]
102
+ },
103
+ "assertion-weakened": {
104
+ "enum": [
105
+ "error",
106
+ "warn",
107
+ "off"
108
+ ]
109
+ },
110
+ "test-removed": {
111
+ "enum": [
112
+ "error",
113
+ "warn",
114
+ "off"
115
+ ]
116
+ },
117
+ "expectation-rewritten": {
118
+ "enum": [
119
+ "error",
120
+ "warn",
121
+ "off"
122
+ ]
123
+ },
124
+ "guard-weakened": {
125
+ "enum": [
126
+ "error",
127
+ "warn",
128
+ "off"
129
+ ]
130
+ },
131
+ "assertion-removed": {
132
+ "enum": [
133
+ "error",
134
+ "warn",
135
+ "off"
136
+ ]
97
137
  }
98
138
  }
99
139
  },