@rigour-labs/core 6.8.0 → 6.8.1-rc.1

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.
Files changed (326) hide show
  1. package/dist/inference/cloud-provider.js +12 -2
  2. package/package.json +11 -8
  3. package/dist/brief/briefing.test.d.ts +0 -1
  4. package/dist/brief/briefing.test.js +0 -107
  5. package/dist/context/automatic-index-cache.test.d.ts +0 -1
  6. package/dist/context/automatic-index-cache.test.js +0 -30
  7. package/dist/context/automatic-index.test.d.ts +0 -1
  8. package/dist/context/automatic-index.test.js +0 -45
  9. package/dist/context/cache-engine.test.d.ts +0 -1
  10. package/dist/context/cache-engine.test.js +0 -99
  11. package/dist/context/dependency-graph.test.d.ts +0 -1
  12. package/dist/context/dependency-graph.test.js +0 -13
  13. package/dist/context/index-status.test.d.ts +0 -1
  14. package/dist/context/index-status.test.js +0 -26
  15. package/dist/context.test.d.ts +0 -1
  16. package/dist/context.test.js +0 -228
  17. package/dist/deep/agent-review.test.d.ts +0 -1
  18. package/dist/deep/agent-review.test.js +0 -53
  19. package/dist/deep/code-context.test.d.ts +0 -1
  20. package/dist/deep/code-context.test.js +0 -48
  21. package/dist/deep/code-pass.test.d.ts +0 -1
  22. package/dist/deep/code-pass.test.js +0 -112
  23. package/dist/deep/code-review-prompt.test.d.ts +0 -1
  24. package/dist/deep/code-review-prompt.test.js +0 -20
  25. package/dist/deep/code-verifier.test.d.ts +0 -1
  26. package/dist/deep/code-verifier.test.js +0 -54
  27. package/dist/deep/diff-tests/calls.test.d.ts +0 -1
  28. package/dist/deep/diff-tests/calls.test.js +0 -33
  29. package/dist/deep/diff-tests/run.test.d.ts +0 -1
  30. package/dist/deep/diff-tests/run.test.js +0 -58
  31. package/dist/deep/fact-extractor.test.d.ts +0 -1
  32. package/dist/deep/fact-extractor.test.js +0 -581
  33. package/dist/deep/parse-findings.test.d.ts +0 -1
  34. package/dist/deep/parse-findings.test.js +0 -25
  35. package/dist/deep/pr-review.test.d.ts +0 -1
  36. package/dist/deep/pr-review.test.js +0 -80
  37. package/dist/deep/prompts.test.d.ts +0 -1
  38. package/dist/deep/prompts.test.js +0 -235
  39. package/dist/deep/reference-pack.test.d.ts +0 -1
  40. package/dist/deep/reference-pack.test.js +0 -46
  41. package/dist/deep/related-changes.test.d.ts +0 -1
  42. package/dist/deep/related-changes.test.js +0 -33
  43. package/dist/deep/review-context-export.test.d.ts +0 -1
  44. package/dist/deep/review-context-export.test.js +0 -32
  45. package/dist/deep/review-tools.test.d.ts +0 -1
  46. package/dist/deep/review-tools.test.js +0 -39
  47. package/dist/deep/risk.test.d.ts +0 -1
  48. package/dist/deep/risk.test.js +0 -101
  49. package/dist/deep/verifier.test.d.ts +0 -1
  50. package/dist/deep/verifier.test.js +0 -635
  51. package/dist/discovery.test.d.ts +0 -1
  52. package/dist/discovery.test.js +0 -93
  53. package/dist/environment.test.d.ts +0 -1
  54. package/dist/environment.test.js +0 -94
  55. package/dist/firewall/firewall.test.d.ts +0 -1
  56. package/dist/firewall/firewall.test.js +0 -117
  57. package/dist/firewall/trust-boundaries.test.d.ts +0 -1
  58. package/dist/firewall/trust-boundaries.test.js +0 -64
  59. package/dist/firewall/trusted-control.test.d.ts +0 -1
  60. package/dist/firewall/trusted-control.test.js +0 -188
  61. package/dist/gates/agent-team.test.d.ts +0 -1
  62. package/dist/gates/agent-team.test.js +0 -113
  63. package/dist/gates/ast.test.d.ts +0 -1
  64. package/dist/gates/ast.test.js +0 -112
  65. package/dist/gates/checkpoint.test.d.ts +0 -1
  66. package/dist/gates/checkpoint.test.js +0 -105
  67. package/dist/gates/content.test.d.ts +0 -1
  68. package/dist/gates/content.test.js +0 -73
  69. package/dist/gates/coverage.test.d.ts +0 -1
  70. package/dist/gates/coverage.test.js +0 -53
  71. package/dist/gates/dedupe-failures.test.d.ts +0 -1
  72. package/dist/gates/dedupe-failures.test.js +0 -12
  73. package/dist/gates/deep-analysis.test.d.ts +0 -1
  74. package/dist/gates/deep-analysis.test.js +0 -86
  75. package/dist/gates/deep-intent.test.d.ts +0 -1
  76. package/dist/gates/deep-intent.test.js +0 -51
  77. package/dist/gates/deep-timeout.test.d.ts +0 -1
  78. package/dist/gates/deep-timeout.test.js +0 -10
  79. package/dist/gates/deprecated-apis.test.d.ts +0 -1
  80. package/dist/gates/deprecated-apis.test.js +0 -318
  81. package/dist/gates/deprecated-dependencies.test.d.ts +0 -1
  82. package/dist/gates/deprecated-dependencies.test.js +0 -55
  83. package/dist/gates/frontend-secret-exposure.test.d.ts +0 -1
  84. package/dist/gates/frontend-secret-exposure.test.js +0 -148
  85. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +0 -1
  86. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +0 -27
  87. package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +0 -1
  88. package/dist/gates/hallucinated-imports/js-resolver-types.test.js +0 -14
  89. package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +0 -1
  90. package/dist/gates/hallucinated-imports-sveltekit.test.js +0 -132
  91. package/dist/gates/hallucinated-imports.test.d.ts +0 -1
  92. package/dist/gates/hallucinated-imports.test.js +0 -1206
  93. package/dist/gates/js-style-context.test.d.ts +0 -1
  94. package/dist/gates/js-style-context.test.js +0 -35
  95. package/dist/gates/logic-drift.test.d.ts +0 -1
  96. package/dist/gates/logic-drift.test.js +0 -52
  97. package/dist/gates/phantom-apis.test.d.ts +0 -1
  98. package/dist/gates/phantom-apis.test.js +0 -396
  99. package/dist/gates/promise-safety.test.d.ts +0 -1
  100. package/dist/gates/promise-safety.test.js +0 -34
  101. package/dist/gates/runner.test.d.ts +0 -1
  102. package/dist/gates/runner.test.js +0 -77
  103. package/dist/gates/scoped-gates.test.d.ts +0 -1
  104. package/dist/gates/scoped-gates.test.js +0 -52
  105. package/dist/gates/security-patterns-owasp.test.d.ts +0 -1
  106. package/dist/gates/security-patterns-owasp.test.js +0 -186
  107. package/dist/gates/security-patterns.test.d.ts +0 -1
  108. package/dist/gates/security-patterns.test.js +0 -194
  109. package/dist/gates/semantic-bugs.test.d.ts +0 -1
  110. package/dist/gates/semantic-bugs.test.js +0 -76
  111. package/dist/gates/side-effect-analysis.test.d.ts +0 -1
  112. package/dist/gates/side-effect-analysis.test.js +0 -162
  113. package/dist/gates/style-drift.test.d.ts +0 -1
  114. package/dist/gates/style-drift.test.js +0 -26
  115. package/dist/gates/test-quality.test.d.ts +0 -1
  116. package/dist/gates/test-quality.test.js +0 -325
  117. package/dist/gates/trusted-reviews.test.d.ts +0 -1
  118. package/dist/gates/trusted-reviews.test.js +0 -16
  119. package/dist/gates/unindexed-reads/queries.test.d.ts +0 -1
  120. package/dist/gates/unindexed-reads/queries.test.js +0 -54
  121. package/dist/gates/unindexed-reads/schema.test.d.ts +0 -1
  122. package/dist/gates/unindexed-reads/schema.test.js +0 -81
  123. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +0 -1
  124. package/dist/gates/unindexed-reads/unindexed-reads.test.js +0 -90
  125. package/dist/hooks/checker.test.d.ts +0 -1
  126. package/dist/hooks/checker.test.js +0 -159
  127. package/dist/hooks/dlp-confidence.test.d.ts +0 -1
  128. package/dist/hooks/dlp-confidence.test.js +0 -51
  129. package/dist/hooks/dlp-feedback.test.d.ts +0 -1
  130. package/dist/hooks/dlp-feedback.test.js +0 -131
  131. package/dist/hooks/input-validator.test.d.ts +0 -1
  132. package/dist/hooks/input-validator.test.js +0 -329
  133. package/dist/hooks/templates.test.d.ts +0 -1
  134. package/dist/hooks/templates.test.js +0 -27
  135. package/dist/inference/brain-placeholder.test.d.ts +0 -1
  136. package/dist/inference/brain-placeholder.test.js +0 -28
  137. package/dist/inference/cloud-provider.test.d.ts +0 -1
  138. package/dist/inference/cloud-provider.test.js +0 -139
  139. package/dist/inference/executable.test.d.ts +0 -1
  140. package/dist/inference/executable.test.js +0 -41
  141. package/dist/inference/http-download.test.d.ts +0 -1
  142. package/dist/inference/http-download.test.js +0 -109
  143. package/dist/inference/llama-engine-checksum.test.d.ts +0 -1
  144. package/dist/inference/llama-engine-checksum.test.js +0 -27
  145. package/dist/inference/llama-engine.test.d.ts +0 -1
  146. package/dist/inference/llama-engine.test.js +0 -51
  147. package/dist/inference/llama-process.test.d.ts +0 -1
  148. package/dist/inference/llama-process.test.js +0 -61
  149. package/dist/inference/local-model.test.d.ts +0 -1
  150. package/dist/inference/local-model.test.js +0 -23
  151. package/dist/inference/model-download.test.d.ts +0 -1
  152. package/dist/inference/model-download.test.js +0 -125
  153. package/dist/inference/model-manager.test.d.ts +0 -1
  154. package/dist/inference/model-manager.test.js +0 -24
  155. package/dist/inference/types.test.d.ts +0 -1
  156. package/dist/inference/types.test.js +0 -19
  157. package/dist/memory/recall.test.d.ts +0 -1
  158. package/dist/memory/recall.test.js +0 -36
  159. package/dist/pattern-index/indexer.test.d.ts +0 -6
  160. package/dist/pattern-index/indexer.test.js +0 -197
  161. package/dist/pattern-index/matcher.test.d.ts +0 -6
  162. package/dist/pattern-index/matcher.test.js +0 -238
  163. package/dist/pattern-index/pattern-reuse.test.d.ts +0 -1
  164. package/dist/pattern-index/pattern-reuse.test.js +0 -76
  165. package/dist/pattern-index/semantic-runtime.test.d.ts +0 -1
  166. package/dist/pattern-index/semantic-runtime.test.js +0 -31
  167. package/dist/pattern-index/staleness.test.d.ts +0 -6
  168. package/dist/pattern-index/staleness.test.js +0 -211
  169. package/dist/review/agent-fixes.test.d.ts +0 -1
  170. package/dist/review/agent-fixes.test.js +0 -32
  171. package/dist/review/backtest-init.test.d.ts +0 -1
  172. package/dist/review/backtest-init.test.js +0 -136
  173. package/dist/review/backtest-judges.test.d.ts +0 -1
  174. package/dist/review/backtest-judges.test.js +0 -32
  175. package/dist/review/backtest-last.test.d.ts +0 -1
  176. package/dist/review/backtest-last.test.js +0 -109
  177. package/dist/review/backtest.test.d.ts +0 -1
  178. package/dist/review/backtest.test.js +0 -183
  179. package/dist/review/baseline.test.d.ts +0 -1
  180. package/dist/review/baseline.test.js +0 -22
  181. package/dist/review/branch-checks.test.d.ts +0 -1
  182. package/dist/review/branch-checks.test.js +0 -48
  183. package/dist/review/check-outcomes.test.d.ts +0 -1
  184. package/dist/review/check-outcomes.test.js +0 -56
  185. package/dist/review/code-patterns.test.d.ts +0 -1
  186. package/dist/review/code-patterns.test.js +0 -142
  187. package/dist/review/dead-code.test.d.ts +0 -1
  188. package/dist/review/dead-code.test.js +0 -154
  189. package/dist/review/deep-runs.test.d.ts +0 -1
  190. package/dist/review/deep-runs.test.js +0 -23
  191. package/dist/review/effectiveness.test.d.ts +0 -1
  192. package/dist/review/effectiveness.test.js +0 -42
  193. package/dist/review/fix-scope.test.d.ts +0 -1
  194. package/dist/review/fix-scope.test.js +0 -104
  195. package/dist/review/generated-files.test.d.ts +0 -1
  196. package/dist/review/generated-files.test.js +0 -25
  197. package/dist/review/migration-order.test.d.ts +0 -1
  198. package/dist/review/migration-order.test.js +0 -62
  199. package/dist/review/quiet.test.d.ts +0 -1
  200. package/dist/review/quiet.test.js +0 -38
  201. package/dist/review/receipt.test.d.ts +0 -1
  202. package/dist/review/receipt.test.js +0 -60
  203. package/dist/review/review-task.test.d.ts +0 -1
  204. package/dist/review/review-task.test.js +0 -89
  205. package/dist/review/review.test.d.ts +0 -1
  206. package/dist/review/review.test.js +0 -203
  207. package/dist/review/reviewer/adapters.test.d.ts +0 -1
  208. package/dist/review/reviewer/adapters.test.js +0 -100
  209. package/dist/review/reviewer/api-judge.test.d.ts +0 -1
  210. package/dist/review/reviewer/api-judge.test.js +0 -108
  211. package/dist/review/reviewer/background.test.d.ts +0 -1
  212. package/dist/review/reviewer/background.test.js +0 -118
  213. package/dist/review/reviewer/context.test.d.ts +0 -1
  214. package/dist/review/reviewer/context.test.js +0 -45
  215. package/dist/review/reviewer/exec.test.d.ts +0 -1
  216. package/dist/review/reviewer/exec.test.js +0 -59
  217. package/dist/review/reviewer/inputs.test.d.ts +0 -1
  218. package/dist/review/reviewer/inputs.test.js +0 -52
  219. package/dist/review/reviewer/panel.test.d.ts +0 -1
  220. package/dist/review/reviewer/panel.test.js +0 -110
  221. package/dist/review/reviewer/record.test.d.ts +0 -1
  222. package/dist/review/reviewer/record.test.js +0 -40
  223. package/dist/review/reviewer/rule-writer.test.d.ts +0 -1
  224. package/dist/review/reviewer/rule-writer.test.js +0 -52
  225. package/dist/review/reviewer/settings.test.d.ts +0 -1
  226. package/dist/review/reviewer/settings.test.js +0 -57
  227. package/dist/review/reviewer/usage.test.d.ts +0 -1
  228. package/dist/review/reviewer/usage.test.js +0 -14
  229. package/dist/review/reviewer.test.d.ts +0 -1
  230. package/dist/review/reviewer.test.js +0 -792
  231. package/dist/review/stories.test.d.ts +0 -1
  232. package/dist/review/stories.test.js +0 -58
  233. package/dist/review/toolchain.test.d.ts +0 -1
  234. package/dist/review/toolchain.test.js +0 -108
  235. package/dist/review/typed/redundancy.test.d.ts +0 -1
  236. package/dist/review/typed/redundancy.test.js +0 -315
  237. package/dist/review/typed/schema-nullability.test.d.ts +0 -1
  238. package/dist/review/typed/schema-nullability.test.js +0 -63
  239. package/dist/review-learning/human-edits.test.d.ts +0 -1
  240. package/dist/review-learning/human-edits.test.js +0 -47
  241. package/dist/review-learning/repo-rules.test.d.ts +0 -1
  242. package/dist/review-learning/repo-rules.test.js +0 -93
  243. package/dist/review-learning/review-learning.test.d.ts +0 -1
  244. package/dist/review-learning/review-learning.test.js +0 -249
  245. package/dist/safety.test.d.ts +0 -1
  246. package/dist/safety.test.js +0 -42
  247. package/dist/semantic/benchmark.test.d.ts +0 -1
  248. package/dist/semantic/benchmark.test.js +0 -22
  249. package/dist/semantic/intent/intent.test.d.ts +0 -1
  250. package/dist/semantic/intent/intent.test.js +0 -46
  251. package/dist/semantic/learn/learn.test.d.ts +0 -1
  252. package/dist/semantic/learn/learn.test.js +0 -67
  253. package/dist/semantic/origins.test.d.ts +0 -1
  254. package/dist/semantic/origins.test.js +0 -77
  255. package/dist/semantic/project-facts.test.d.ts +0 -1
  256. package/dist/semantic/project-facts.test.js +0 -45
  257. package/dist/semantic/sites/call-sites.test.d.ts +0 -1
  258. package/dist/semantic/sites/call-sites.test.js +0 -46
  259. package/dist/services/adaptive-thresholds.test.d.ts +0 -1
  260. package/dist/services/adaptive-thresholds.test.js +0 -53
  261. package/dist/services/agent-history.test.d.ts +0 -1
  262. package/dist/services/agent-history.test.js +0 -69
  263. package/dist/services/context-scope-summary.test.d.ts +0 -1
  264. package/dist/services/context-scope-summary.test.js +0 -17
  265. package/dist/services/context-telemetry-service.test.d.ts +0 -1
  266. package/dist/services/context-telemetry-service.test.js +0 -182
  267. package/dist/services/cursor-usage-sync.test.d.ts +0 -1
  268. package/dist/services/cursor-usage-sync.test.js +0 -173
  269. package/dist/services/engineering-knowledge-graph.test.d.ts +0 -1
  270. package/dist/services/engineering-knowledge-graph.test.js +0 -76
  271. package/dist/services/model-pricing.test.d.ts +0 -1
  272. package/dist/services/model-pricing.test.js +0 -44
  273. package/dist/services/observed-savings.test.d.ts +0 -1
  274. package/dist/services/observed-savings.test.js +0 -37
  275. package/dist/services/score-history.test.d.ts +0 -1
  276. package/dist/services/score-history.test.js +0 -61
  277. package/dist/smoke.test.d.ts +0 -1
  278. package/dist/smoke.test.js +0 -17
  279. package/dist/storage/cache-cleanup.test.d.ts +0 -1
  280. package/dist/storage/cache-cleanup.test.js +0 -54
  281. package/dist/storage/context-telemetry.test.d.ts +0 -1
  282. package/dist/storage/context-telemetry.test.js +0 -80
  283. package/dist/storage/db.test.d.ts +0 -1
  284. package/dist/storage/db.test.js +0 -46
  285. package/dist/storage/fix-lessons.test.d.ts +0 -1
  286. package/dist/storage/fix-lessons.test.js +0 -61
  287. package/dist/storage/lessons.test.d.ts +0 -1
  288. package/dist/storage/lessons.test.js +0 -81
  289. package/dist/storage/local-encryption.test.d.ts +0 -1
  290. package/dist/storage/local-encryption.test.js +0 -34
  291. package/dist/storage/local-memory.test.d.ts +0 -1
  292. package/dist/storage/local-memory.test.js +0 -55
  293. package/dist/storage/share-memory.test.d.ts +0 -1
  294. package/dist/storage/share-memory.test.js +0 -34
  295. package/dist/storage/team-diagnostics.test.d.ts +0 -1
  296. package/dist/storage/team-diagnostics.test.js +0 -51
  297. package/dist/storage/team-scope.test.d.ts +0 -1
  298. package/dist/storage/team-scope.test.js +0 -22
  299. package/dist/storage/team-store.test.d.ts +0 -1
  300. package/dist/storage/team-store.test.js +0 -11
  301. package/dist/storage/team-sync-scope.test.d.ts +0 -1
  302. package/dist/storage/team-sync-scope.test.js +0 -100
  303. package/dist/storage/team-vector-store.test.d.ts +0 -1
  304. package/dist/storage/team-vector-store.test.js +0 -56
  305. package/dist/storage/telemetry-scope.test.d.ts +0 -1
  306. package/dist/storage/telemetry-scope.test.js +0 -38
  307. package/dist/task/thread.test.d.ts +0 -1
  308. package/dist/task/thread.test.js +0 -133
  309. package/dist/telemetry/telemetry.test.d.ts +0 -1
  310. package/dist/telemetry/telemetry.test.js +0 -64
  311. package/dist/types/index.test.d.ts +0 -1
  312. package/dist/types/index.test.js +0 -33
  313. package/dist/utils/command-line.test.d.ts +0 -1
  314. package/dist/utils/command-line.test.js +0 -8
  315. package/dist/utils/diff-removed.test.d.ts +0 -1
  316. package/dist/utils/diff-removed.test.js +0 -29
  317. package/dist/utils/diff.test.d.ts +0 -1
  318. package/dist/utils/diff.test.js +0 -38
  319. package/dist/utils/glob-paths.test.d.ts +0 -1
  320. package/dist/utils/glob-paths.test.js +0 -40
  321. package/dist/utils/profile.test.d.ts +0 -1
  322. package/dist/utils/profile.test.js +0 -68
  323. package/dist/utils/scanner.test.d.ts +0 -1
  324. package/dist/utils/scanner.test.js +0 -48
  325. package/dist/utils/scope.test.d.ts +0 -1
  326. package/dist/utils/scope.test.js +0 -38
@@ -1,792 +0,0 @@
1
- import { execFileSync } from 'child_process';
2
- import fs from 'fs';
3
- import os from 'os';
4
- import path from 'path';
5
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
- import { ConfigSchema } from '../types/index.js';
7
- import { reviewerBlocks, runReviewer } from './reviewer.js';
8
- import { dismissReviewerFinding } from './reviewer/context.js';
9
- import { reviewStatus } from './reviewer/background.js';
10
- import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
11
- import { account, attachServedRules, carryResolved, changedLinesOf, checkoutSearch, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
12
- import { recordIntact, recordLines } from './reviewer/record.js';
13
- import { readThread } from '../task/thread.js';
14
- let repo;
15
- const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
16
- const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
17
- const PR = { number: 42, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'Every read is bounded at both ends.' };
18
- const REVIEWS = [
19
- { id: 1, user: { login: 'ci-bot', type: 'Bot' }, body: 'automated', state: 'COMMENTED', submitted_at: '2026-10-01', commit_id: 'aaaaaaaaa' },
20
- { id: 2, user: { login: 'author', type: 'User' }, body: 'self note', state: 'COMMENTED', submitted_at: '2026-10-02', commit_id: 'aaaaaaaaa' },
21
- { id: 3, user: { login: 'senior', type: 'User' }, body: 'Two blocking points.', state: 'CHANGES_REQUESTED', submitted_at: '2026-10-03', commit_id: 'aaaaaaaaa' },
22
- ];
23
- const INLINE = [{ id: 7, user: { login: 'senior', type: 'User' }, path: 'src/job.ts', line: 12, body: 'Check the lock before the first read.', created_at: '2026-10-03', updated_at: '2026-10-03' }];
24
- const EMPTY = { prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: true, evidence: 'a.ts:1' }], redundant: [], reads: [], scans: [], merge_impact: [], findings: [], carried: [], resolved_previous: [] };
25
- /** Real git; scripted gh; agent CLIs that record what they were shown and answer `answer` (a function of the reviewer's name). */
26
- function fakes(answer, seen, pr = PR) {
27
- return async (command, args, options) => {
28
- if (command === 'git') {
29
- try {
30
- return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8' }), stderr: '' };
31
- }
32
- catch (error) {
33
- return { exitCode: 1, stdout: '', stderr: String(error.message) };
34
- }
35
- }
36
- if (command === 'gh') {
37
- seen.ghArgs.push(args);
38
- if (args[0] === 'auth')
39
- return { exitCode: 0, stdout: 'token-for-account\n', stderr: '' };
40
- seen.ghToken = options.env?.GH_TOKEN;
41
- if (args[0] === 'pr')
42
- return pr ? { exitCode: 0, stdout: JSON.stringify(pr), stderr: '' } : { exitCode: 1, stdout: '', stderr: 'no pull requests found for branch "feature"' };
43
- if (args[1].endsWith('/reviews'))
44
- return { exitCode: 0, stdout: JSON.stringify(REVIEWS), stderr: '' };
45
- if (args[1].endsWith('/comments'))
46
- return { exitCode: 0, stdout: JSON.stringify(INLINE), stderr: '' };
47
- return { exitCode: 1, stdout: '', stderr: 'unexpected gh call' };
48
- }
49
- const binary = path.basename(command).replace(/\.(cmd|exe)$/, '');
50
- if (args[0] === '--version')
51
- return (seen.installed ?? ['claude', 'cursor-agent']).includes(binary) ? { exitCode: 0, stdout: `${seen.versions?.[command] ?? '1.0.0'}\n`, stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' };
52
- seen.ran.push(command);
53
- (seen.args ??= []).push(args);
54
- (seen.unset ??= []).push(options.unset);
55
- (seen.env ??= []).push(options.env);
56
- const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
57
- const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
58
- seen.prompts.push(prompt);
59
- // Paths as the prompt names them, on either separator (Windows writes `D:\...`).
60
- for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json|team-knowledge\.md))/g)) {
61
- seen.files[path.basename(match[1])] = fs.readFileSync(match[1], 'utf8');
62
- }
63
- const reply = answer(name);
64
- if (typeof reply !== 'string')
65
- return reply;
66
- const stdout = binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : binary === 'codex' ? JSON.stringify({ type: 'item.completed', item: { text: reply } }) : JSON.stringify({ result: reply });
67
- return { exitCode: 0, stdout, stderr: '' };
68
- };
69
- }
70
- const seenNow = () => ({ prompts: [], files: {}, ghArgs: [], ran: [] });
71
- /** A PATH of our own, so the test sees only the agent CLIs it creates (the fake exec answers for them by name). */
72
- let bins;
73
- const originalPath = process.env.PATH;
74
- function installFake(dir, name) {
75
- const file = path.join(dir, process.platform === 'win32' ? `${name}.cmd` : name);
76
- fs.writeFileSync(file, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
77
- return file;
78
- }
79
- beforeEach(() => {
80
- bins = [fs.mkdtempSync(path.join(os.tmpdir(), 'bin-a-')), fs.mkdtempSync(path.join(os.tmpdir(), 'bin-b-'))];
81
- for (const name of ['claude', 'cursor-agent'])
82
- installFake(bins[0], name);
83
- process.env.PATH = [...bins, originalPath ?? ''].join(path.delimiter);
84
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'reviewer-'));
85
- git('init', '-q', '-b', 'main');
86
- git('config', 'user.email', 't@example.com');
87
- git('config', 'user.name', 't');
88
- git('config', 'commit.gpgsign', 'false');
89
- fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
90
- git('add', '-A');
91
- git('commit', '-qm', 'init');
92
- git('checkout', '-qb', 'feature');
93
- fs.mkdirSync(path.join(repo, 'src'));
94
- fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 1;\n}\n');
95
- git('add', '-A');
96
- git('commit', '-qm', 'job');
97
- });
98
- afterEach(() => {
99
- process.env.PATH = originalPath;
100
- for (const dir of [repo, ...bins])
101
- fs.rmSync(dir, { recursive: true, force: true });
102
- });
103
- describe('the reviewer', () => {
104
- it('works from every human review with inline comments and the description, written to files, and blocks on an open point', async () => {
105
- const seen = seenNow();
106
- const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }] });
107
- const result = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
108
- expect(seen.files['previous-reviews.md']).toContain('Review by senior');
109
- expect(seen.files['previous-reviews.md']).toContain('- 2026-10-03 src/job.ts:12: Check the lock before the first read.');
110
- expect(seen.files['previous-reviews.md']).not.toContain('automated');
111
- expect(seen.files['previous-reviews.md']).not.toContain('self note');
112
- expect(seen.files['pr-description.md']).toBe('Every read is bounded at both ends.');
113
- expect(seen.files['full.diff']).toContain('+export function job()');
114
- expect(seen.ghToken).toBe('token-for-account');
115
- expect(seen.ghArgs[1].slice(0, 3)).toEqual(['pr', 'view', 'feature']); // by branch: the commit is not on the forge yet
116
- expect(result).toMatchObject({ outcome: 'findings', reviewers: ['claude'], scope: 'full', cached: false, previousReview: 'senior, 2026-10-03 (1 review)', pr: 42, costUsd: 1.5 });
117
- expect(result.items).toEqual([expect.objectContaining({ kind: 'prior', issue: 'lock before read', evidence: 'src/job.ts:2', reviewer: 'claude' })]);
118
- expect(reviewerBlocks(result)).toBe(true);
119
- });
120
- it('caches the verdict per commit and inputs, so the same push is free, and the next commit gets a delta review that carries what is not accounted for', async () => {
121
- const seen = seenNow();
122
- const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {', consequence: 'a second run reads stale rows', why: 'x' }] });
123
- const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
124
- expect(first.items).toHaveLength(1);
125
- const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
126
- expect(again.cached).toBe(true);
127
- expect(seen.prompts).toHaveLength(1);
128
- expect(fs.readdirSync(repo).sort()).toEqual(['.git', 'a.ts', 'src']); // nothing written to the working tree
129
- fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
130
- git('commit', '-qam', 'tweak');
131
- // What the checks found changed with the commit: the context changes, the instructions do not, so it is still a delta.
132
- const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined, { checks: ['src/job.ts:2 Unused export `other`'] });
133
- expect(delta.scope).toBe('delta');
134
- expect(seen.prompts[1]).toContain('DELTA MODE');
135
- expect(seen.files['delta.diff']).toContain('- return 1;');
136
- expect(JSON.parse(seen.files['previous-open.json'])).toHaveLength(1);
137
- expect(delta.items).toEqual([expect.objectContaining({ issue: 'returns before the lock', status: 'not accounted for' })]);
138
- expect(delta.outcome).toBe('findings');
139
- // The human point the previous verdict resolved (a.ts untouched) was carried, not judged again.
140
- expect(JSON.parse(seen.files['previous-resolved.json'])).toEqual([expect.objectContaining({ point: 'lock before read', resolved: true })]);
141
- expect(delta.answerInReply).toEqual([]);
142
- });
143
- it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
144
- const seen = seenNow();
145
- const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named', consequence: 'a second run reads stale rows' }] }), seen), () => undefined);
146
- expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
147
- expect(first.unverified.map(i => i.file)).toEqual(['src/ghost.ts', '']); // a slip and a finding with no place to check: shown, never a block
148
- const id = first.items[0].id;
149
- fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
150
- git('commit', '-qam', 'fix');
151
- const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], resolved_previous: [{ id, evidence: 'src/job.ts:2 the lock comes first now' }] }), seen), () => undefined);
152
- expect(delta.outcome).toBe('passed');
153
- expect(delta.resolved).toEqual([{ item: expect.objectContaining({ id }), evidence: 'src/job.ts:2 the lock comes first now' }]);
154
- });
155
- it('at push, asks a model only when someone will read the push: an open, non-draft pull request', async () => {
156
- const seen = seenNow();
157
- const none = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'push' });
158
- expect(none).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('no pull request for feature') });
159
- expect(reviewerBlocks(none)).toBe(false);
160
- expect(seen.prompts).toHaveLength(0);
161
- const draft = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, { ...PR, isDraft: true }), () => undefined, { trigger: 'push' });
162
- expect(draft.reason).toContain('is a draft');
163
- const asked = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
164
- expect(asked.outcome).toBe('passed'); // on request it reviews without a pull request
165
- expect(seen.prompts).toHaveLength(1);
166
- });
167
- it('for a backtest, reads the named pull request and hides every review and comment from the reviewed moment on', async () => {
168
- const seen = seenNow();
169
- const hidden = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-03', force: true });
170
- expect(seen.ghArgs.find(a => a[0] === 'pr')?.slice(0, 3)).toEqual(['pr', 'view', '42']);
171
- expect(seen.files['previous-reviews.md']).toBe('none\n');
172
- expect(hidden.outcome).toBe('passed');
173
- const shown = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-04', force: true });
174
- expect(seen.files['previous-reviews.md']).toContain('Review by senior');
175
- expect(shown).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('did not report on the human reviews') });
176
- });
177
- it('for a backtest, gives the description as it read at the review, never a later edit', async () => {
178
- const versions = { lastEditedAt: '2026-10-06T00:00:00Z', body: 'today: refunds are issued by the nightly job', userContentEdits: { totalCount: 3, nodes: [
179
- { editedAt: '2026-10-06T00:00:00Z', diff: 'today: refunds are issued by the nightly job' },
180
- { editedAt: '2026-10-02T00:00:00Z', diff: 'then: refunds are issued on request' },
181
- { editedAt: '2026-09-30T00:00:00Z', diff: 'first draft' },
182
- ] } };
183
- const withEdits = (answer, seen, graphql) => {
184
- const base = fakes(answer, seen);
185
- return async (command, args, options) => command === 'gh' && args[0] === 'api' && args[1] === 'graphql'
186
- ? { exitCode: graphql ? 0 : 1, stdout: JSON.stringify({ data: { repository: { pullRequest: graphql } } }), stderr: '' }
187
- : base(command, args, options);
188
- };
189
- const reply = () => JSON.stringify({ ...EMPTY, prior_points: [] });
190
- const seen = seenNow();
191
- await runReviewer(repo, 'main', config, withEdits(reply, seen, versions), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
192
- expect(seen.files['pr-description.md']).toBe('then: refunds are issued on request');
193
- await runReviewer(repo, 'main', config, withEdits(reply, seen, null), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
194
- expect(seen.files['pr-description.md']).toContain('could not be recovered');
195
- });
196
- it('blind, reviews the commit alone and never asks GitHub', async () => {
197
- const seen = seenNow();
198
- const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { blind: true, trigger: 'backtest', force: true });
199
- expect(result.outcome).toBe('passed');
200
- expect(seen.ghArgs).toEqual([]);
201
- expect(seen.files['previous-reviews.md']).toBe('none\n');
202
- });
203
- it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
204
- const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
205
- expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('cursor: no answer (exit 1)'), mode: { degraded: expect.stringContaining('claude gave no verdict, cursor judged instead') } }); // asked twice, then the spare judge, which failed too
206
- const prose = await runReviewer(repo, 'main', config, fakes(() => 'Looks good to me!', seenNow()), () => undefined);
207
- expect(prose).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
208
- const broken = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'HTTP 500' } : fakes(() => '', seenNow())(command, args, options);
209
- expect(await runReviewer(repo, 'main', config, broken, () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('could not read the pull request') });
210
- const seen = { ...seenNow(), installed: [] };
211
- expect(await runReviewer(repo, 'main', config, fakes(() => '', seen), () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no reviewer installed') });
212
- for (const result of [crashed, prose])
213
- expect(reviewerBlocks(result)).toBe(true);
214
- });
215
- it('keeps where each judge run spent its tokens and what it read, labelled, in the local verdict', async () => {
216
- const seen = seenNow();
217
- const base = fakes(() => JSON.stringify(EMPTY), seen);
218
- const streaming = async (command, args, options) => {
219
- if (path.basename(command).replace(/\.(cmd|exe)$/, '') !== 'claude' || args[0] === '--version')
220
- return base(command, args, options); // claude.cmd on Windows
221
- const prompt = args[args.indexOf('-p') + 1];
222
- const diff = /(\S+full\.diff)/.exec(prompt)[1];
223
- const call = (id, name, input) => ({ type: 'assistant', message: { id: `m-${id}`, usage: { input_tokens: 1, cache_read_input_tokens: 100, cache_creation_input_tokens: 10, output_tokens: 5 }, content: [{ type: 'tool_use', id, name, input }] } });
224
- const events = [
225
- call('1', 'Read', { file_path: diff }), call('2', 'Read', { file_path: path.join(repo, 'src/job.ts') }), call('3', 'Read', { file_path: path.join(repo, 'a.ts') }),
226
- call('4', 'Bash', { command: 'git log -3' }), call('5', 'Grep', { pattern: 'job' }),
227
- { type: 'result', result: JSON.stringify(EMPTY), total_cost_usd: 0.2, usage: { input_tokens: 5, output_tokens: 25 } },
228
- ];
229
- return { exitCode: 0, stdout: events.map(e => JSON.stringify(e)).join('\n'), stderr: '' };
230
- };
231
- await runReviewer(repo, 'main', config, streaming, () => undefined, { force: true });
232
- const store = path.join(repo, '.git', 'rigour-reviewer');
233
- const verdict = fs.readdirSync(store).filter(f => /^[0-9a-f]{40}\.[0-9a-f]{8}\.json$/.test(f)).map(f => JSON.parse(fs.readFileSync(path.join(store, f), 'utf8')))[0];
234
- expect(verdict.reviewers[0].trace).toMatchObject({ turns: 5, usage: { input: 5, cacheRead: 500, cacheWrite: 50, output: 25 } });
235
- expect(verdict.reviewers[0].trace.calls.map((c) => c.category)).toEqual(['rigour-input', 'changed-file', 'other-file', 'git', 'search']);
236
- });
237
- it('serves the repository\'s own rules to the judge with ids, and blocks on a requirement the judge shows broken', async () => {
238
- fs.writeFileSync(path.join(repo, 'AGENTS.md'), '# Rules\n\n- `src/job.ts` must take the lock before its first read.\n- Prefer early returns.\n');
239
- const seen = seenNow();
240
- const answer = () => {
241
- const id = /- \[([0-9a-f]{10})\] \(AGENTS\.md, requirement\)/.exec(seen.files['team-knowledge.md'] ?? '')?.[1];
242
- return JSON.stringify({ ...EMPTY, rules: [{ id, status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', evidence: 'reads before any lock' }] });
243
- };
244
- const result = await runReviewer(repo, 'main', config, fakes(answer, seen), () => undefined, { force: true });
245
- expect(seen.files['team-knowledge.md']).toContain('(AGENTS.md, requirement) `src/job.ts` must take the lock before its first read.');
246
- expect(result.items.map(i => [i.class, i.file, i.line])).toEqual([['repo-rule', 'src/job.ts', 2]]);
247
- expect(result.rules).toEqual({ checked: 1, followed: 0, broken: 1, notApplicable: 0 });
248
- });
249
- it('writes the record of the review beside the verdict, intact, and returns the same record on a cached read', async () => {
250
- const seen = seenNow();
251
- const answer = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', input: 'two runs', consequence: 'two emails', quote: 'return 1;', severity: 'blocking' }] });
252
- const first = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
253
- expect(first.record).toMatchObject({ scope: 'full', verified: { blocking: [expect.objectContaining({ issue: 'returns before the lock' })], should_fix: [] }, reported: { human_reviews: 1 }, judges: [expect.objectContaining({ reviewer: 'claude', cost_usd: 1.5 })] });
254
- expect(first.recordPath).toMatch(/\.record\.json$/);
255
- const onDisk = JSON.parse(fs.readFileSync(first.recordPath, 'utf8'));
256
- expect(recordIntact(onDisk)).toBe(true);
257
- const again = await runReviewer(repo, 'main', config, fakes(() => { throw new Error('a cached read never runs a judge'); }, seen), () => undefined);
258
- expect(again.cached).toBe(true);
259
- expect(again.record?.integrity).toBe(first.record?.integrity);
260
- });
261
- it('reviews through the API judge when the team configured one and its key is set, with the same prompt and accounting', async () => {
262
- const seen = seenNow();
263
- const calls = [];
264
- const fetchImpl = (async (_url, init) => {
265
- const body = JSON.parse(init.body);
266
- calls.push(body);
267
- const last = body.messages.at(-1);
268
- const message = last.role === 'user'
269
- ? { role: 'assistant', content: null, tool_calls: [{ id: 't1', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: /(\S+full\.diff)/.exec(last.content)[1] }) } }] }
270
- : { role: 'assistant', content: JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', input: 'two runs', consequence: 'two emails', quote: 'return 1;', severity: 'blocking' }] }) };
271
- return new Response(JSON.stringify({ choices: [{ message }], usage: { prompt_tokens: 100, completion_tokens: 20, cost: 0.05 } }), { status: 200 });
272
- });
273
- const apiConfig = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['api'], api: { url: 'https://example.test/v1', model: 'qwen3-coder', key_env: 'TEST_JUDGE_KEY' }, reasoning: { api: 'low' } } } });
274
- const without = await runReviewer(repo, 'main', apiConfig, fakes(() => '', seen), () => undefined, { fetch: fetchImpl });
275
- expect(without).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no reviewer installed') }); // the key is not set
276
- process.env.TEST_JUDGE_KEY = 'secret';
277
- try {
278
- const result = await runReviewer(repo, 'main', apiConfig, fakes(() => '', seen), () => undefined, { fetch: fetchImpl, force: true });
279
- expect(result).toMatchObject({ outcome: 'findings', reviewers: ['api'], costUsd: 0.1, items: [expect.objectContaining({ issue: 'returns before the lock', reviewer: 'api' })] });
280
- expect(calls[0].reasoning_effort).toBe('low');
281
- expect(calls[0].messages[1].content).toContain('full.diff'); // the same prompt a CLI judge gets
282
- expect(result.record?.judges).toEqual([{ reviewer: 'api', version: 'qwen3-coder', cost_usd: 0.1, turns: 2 }]);
283
- }
284
- finally {
285
- delete process.env.TEST_JUDGE_KEY;
286
- }
287
- });
288
- it('replaces a judge that gives nothing with the next one installed, and says so', async () => {
289
- const seen = seenNow();
290
- const silent = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 0 } }), { status: 200 }));
291
- const twoJudges = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['api', 'claude'], api: { url: 'https://example.test/v1', model: 'silent-model', key_env: 'TEST_JUDGE_KEY' } } } });
292
- process.env.TEST_JUDGE_KEY = 'secret';
293
- try {
294
- const result = await runReviewer(repo, 'main', twoJudges, fakes(() => JSON.stringify(EMPTY), seen), () => undefined, { fetch: silent, force: true });
295
- expect(result).toMatchObject({ outcome: 'passed', reviewers: ['claude'], mode: { degraded: expect.stringContaining('api gave no verdict, claude judged instead') } });
296
- expect(seen.prompts).toHaveLength(1); // claude ran once, after the api judge's two empty answers
297
- }
298
- finally {
299
- delete process.env.TEST_JUDGE_KEY;
300
- }
301
- });
302
- it('asks a judge once more after an answer that is not a verdict, and is unavailable only when the second is not one either', async () => {
303
- const seen = seenNow();
304
- let calls = 0;
305
- const slipOnce = await runReviewer(repo, 'main', config, fakes(() => (++calls === 1 ? '{"prior_points":[], "findings":[{"class"' : JSON.stringify(EMPTY)), seen), () => undefined, { force: true });
306
- expect(slipOnce.outcome).toBe('passed');
307
- expect(seen.prompts).toHaveLength(2);
308
- let crashes = 0;
309
- const crashOnce = seenNow();
310
- const recovered = await runReviewer(repo, 'main', config, fakes(() => (++crashes === 1 ? { exitCode: 1, stdout: '', stderr: 'API error' } : JSON.stringify(EMPTY)), crashOnce), () => undefined, { force: true });
311
- expect(recovered.outcome).toBe('passed'); // a run that died is asked once more too
312
- const twice = seenNow();
313
- const slipTwice = await runReviewer(repo, 'main', config, fakes(() => 'not json', twice), () => undefined, { force: true });
314
- expect(slipTwice).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
315
- expect(twice.prompts).toHaveLength(3); // once more, then the spare judge once: never a loop
316
- });
317
- it('says what was asked and that nothing ran when a review ends early, with why a judge is missing', async () => {
318
- const seen = { ...seenNow(), installed: ['claude'] }; // cursor is listed but not installed
319
- const unreadable = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'gh auth login required' } : fakes(() => '', seen)(command, args, options);
320
- const result = await runReviewer(repo, 'main', config, unreadable, () => undefined, { choice: { mode: 'full', panel: true } });
321
- expect(result).toMatchObject({ outcome: 'unavailable', reviewers: ['claude'] });
322
- expect(result.mode).toMatchObject({ asked: 'panel', ran: 'none', source: 'flag', degraded: expect.stringContaining('not installed: cursor-agent') });
323
- const early = await runReviewer(repo, 'main', config, fakes(() => '', { ...seenNow(), installed: [] }), () => undefined, { choice: { mode: 'full', panel: true } });
324
- expect(early).toMatchObject({ outcome: 'unavailable', mode: { asked: 'panel', ran: 'none', source: 'flag' } }); // before any judge was found
325
- });
326
- it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
327
- const seen = seenNow();
328
- const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows', quote: 'export const a = 1;' }] : [] });
329
- const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
330
- expect(result.reviewers).toEqual(['claude', 'cursor']);
331
- expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']]);
332
- expect(result.notes.map(i => [i.kind, i.file, i.reviewer])).toEqual([['finding', 'a.ts', 'cursor']]); // a.ts is not in the change: what the code already had
333
- expect(seen.prompts).toHaveLength(2);
334
- });
335
- });
336
- describe('choosing reviewers', () => {
337
- it('runs the newest installed copy of a CLI, not the first on PATH', async () => {
338
- const seen = seenNow();
339
- const older = installFake(bins[0], 'claude');
340
- const newer = installFake(bins[1], 'claude');
341
- seen.versions = { [older]: '2.0.34 (Claude Code)', [newer]: '2.1.289 (Claude Code)' };
342
- const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen), () => undefined);
343
- expect(result.outcome).toBe('passed');
344
- expect(seen.ran).toEqual([newer]);
345
- });
346
- it('reads the vendors on the trailers, prefers another one in cross mode, and pairs two vendors in full mode', () => {
347
- const authors = vendorsOf('Claude Opus <noreply@example.com>\n');
348
- expect([...authors]).toEqual(['anthropic']);
349
- const installed = new Set(['claude', 'cursor', 'codex']);
350
- expect(selectReviewers(['claude', 'cursor', 'codex'], 'single', authors, installed)).toEqual(['claude']);
351
- expect(selectReviewers(['claude', 'cursor', 'codex'], 'cross', authors, installed)).toEqual(['cursor']);
352
- expect(selectReviewers(['claude', 'cursor', 'codex'], 'full', authors, installed)).toEqual(['cursor', 'claude']);
353
- expect(selectReviewers(['claude', 'cursor'], 'cross', authors, new Set(['claude']))).toEqual(['claude']); // falls back to what is installed
354
- expect(selectReviewers(['claude'], 'full', new Set(), new Set())).toEqual([]);
355
- });
356
- });
357
- describe('verdicts', () => {
358
- it('finds the verdict when the reviewer wraps it in a summary or a code fence, and refuses prose', () => {
359
- const verdict = JSON.stringify(EMPTY);
360
- for (const text of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
361
- expect(parseVerdict(text, true, 'claude', {})).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
362
- }
363
- expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
364
- expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
365
- });
366
- it('keeps the journey, sibling parity and claims as working notes, never blocks, and reads a verdict cached before they existed', () => {
367
- const verdict = {
368
- ...EMPTY,
369
- journey: [
370
- { file: 'src/w.ts', line: 4, what: 'stamps sent_at', cleared_by: null, retry_safe: false, overlap_safe: true, can_move_back: true, keys: [{ name: 'dedupeKey', inputs: 'updated_at', stable_under_edit: false }] },
371
- { file: 'src/w.ts', line: 9, what: 'caches the token', cleared_by: 'ttl', retry_safe: true, overlap_safe: true, can_move_back: null },
372
- ],
373
- siblings: [
374
- { changed: 'src/a/run.ts:3', sibling: 'src/b/run.ts:7', needs_same_change: true, has_it: false, why: 'same lock' },
375
- { changed: 'src/a/run.ts:3', sibling: 'src/c/run.ts:2', needs_same_change: true, has_it: true },
376
- { changed: 'src/a/run.ts:3', sibling: 'src/d/run.ts:2', needs_same_change: false, has_it: false },
377
- ],
378
- claims: [
379
- { source: 'description', claim: 'at worst one email', file: 'src/w.ts', line: 12, holds: false, evidence: 'loop sends per row' },
380
- { source: 'comment', claim: 'runs daily', file: 'src/cron.ts', line: 1, holds: true },
381
- ],
382
- };
383
- const { open, notes } = account(verdict, undefined, () => true);
384
- expect(open).toEqual([]);
385
- expect(notes.map(i => `${i.kind} ${i.class} ${i.file}:${i.line}`)).toEqual([
386
- 'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4',
387
- 'sibling correctness src/b/run.ts:7', 'claim stale-claim src/w.ts:12',
388
- ]);
389
- expect(notes[3].issue).toContain('needs the same change as src/a/run.ts:3: same lock');
390
- const cached = { ...EMPTY };
391
- delete cached.journey;
392
- delete cached.siblings;
393
- delete cached.claims;
394
- expect(account(cached, undefined, () => true).open).toEqual([]);
395
- expect(mergeVerdicts([cached, { ...verdict, reviewer: 'codex' }]).claims).toHaveLength(2);
396
- });
397
- it('blocks on a finding only when the code it quotes is at the line it names, and never carries an old working note as a block', () => {
398
- const verify = checkoutVerifier(repo);
399
- const at = (quote, line = 2) => account({ ...EMPTY, prior_points: [], findings: [{ class: 'correctness', file: 'src/job.ts', line, issue: 'returns before the lock', input: 'two runs at once', consequence: 'two emails', ...(quote === undefined ? {} : { quote }) }] }, undefined, verify);
400
- expect(at(' return 1;').open).toHaveLength(1);
401
- expect(at('return 1;').open).toHaveLength(1); // whitespace aside
402
- expect(at('return 1;', 9)).toMatchObject({ open: [], unverified: [expect.objectContaining({ issue: 'returns before the lock' })] }); // past the end
403
- expect(at('return 99;')).toMatchObject({ open: [], unverified: [expect.anything()] }); // not in the file
404
- expect(at()).toMatchObject({ open: [], unverified: [expect.anything()] }); // no quote at all
405
- const old = { id: 'r1', kind: 'read', class: 'production-cost', file: 'src/q.ts', line: 3, issue: 'window not bounded' };
406
- const carried = account({ ...EMPTY, prior_points: [] }, [old], verify);
407
- expect(carried).toMatchObject({ open: [], notes: [expect.objectContaining({ id: 'r1' })] });
408
- });
409
- it('shows a team lesson the change repeats as a note, never a block on its own', () => {
410
- const verdict = { ...EMPTY, lessons: [
411
- { lesson: 'Regenerate the API client after changing the schema.', applies: true, file: 'src/schema.ts', line: 3, evidence: 'schema.ts changed, client not' },
412
- { lesson: 'Paginate with keyset.', applies: false, file: 'src/scan.ts', line: 9 },
413
- ] };
414
- const { open, notes } = account(verdict, undefined, () => true);
415
- expect(open).toEqual([]);
416
- expect(notes.map(n => [n.kind, n.class, n.issue])).toEqual([['lesson', 'team-lesson', 'repeats a team lesson: Regenerate the API client after changing the schema.']]);
417
- });
418
- it('blocks only on what it can show: a quoted open human point, a blocking finding, never a missing thing that is there or a point a human accepted', () => {
419
- const verify = checkoutVerifier(repo);
420
- const decide = (v) => account({ ...EMPTY, prior_points: [], ...v }, undefined, verify);
421
- const open = { point: 'take the lock before the first read', severity: 'blocking', resolved: false };
422
- expect(decide({ prior_points: [{ ...open, file: 'src/job.ts', line: 2, quote: 'return 1;' }] }).open).toHaveLength(1);
423
- expect(decide({ prior_points: [open] })).toMatchObject({ open: [], unverified: [expect.objectContaining({ kind: 'prior' })] }); // a later commit may have done it: no quote, no block
424
- const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'job never closes the connection', input: 'every run', consequence: 'one connection leaks per run', quote: 'return 1;' };
425
- expect(decide({ findings: [{ ...finding, absent: 'return 1' }] })).toMatchObject({ open: [], unverified: [expect.anything()] }); // "missing", but the file has it
426
- expect(decide({ findings: [{ ...finding, absent: 'conn.close(' }] }).open).toHaveLength(1);
427
- expect(decide({ findings: [{ ...finding, severity: 'should' }] })).toMatchObject({ open: [], advisory: [expect.anything()], unverified: [] }); // a verified should-fix: shown
428
- expect(decide({ findings: [{ ...finding, severity: 'should', quote: 'return 99;' }] })).toMatchObject({ open: [], advisory: [], unverified: [expect.anything()] }); // a should-fix it cannot show: not a claim worth time
429
- const accepted = { point: 'job never closes the connection after the read', severity: 'non-blocking', resolved: false };
430
- expect(decide({ prior_points: [accepted], findings: [finding] })).toMatchObject({ open: [], advisory: [expect.anything()] }); // a human raised it and accepted it
431
- });
432
- it('blocks on a broken requirement rule only with its quote, shows broken guidance, and takes the rule\'s words from what Rigour served', () => {
433
- const verify = checkoutVerifier(repo);
434
- const served = [
435
- { id: 'r1', source: 'AGENTS.md', text: 'Every job must take the lock before its first read.', requirement: true },
436
- { id: 'r2', source: 'AGENTS.md', text: 'Prefer small functions.', requirement: false },
437
- ];
438
- const judged = (answers) => {
439
- const verdict = { ...EMPTY, prior_points: [], rules: answers };
440
- attachServedRules(verdict, served);
441
- return { verdict, ...account(verdict, undefined, verify) };
442
- };
443
- const broken = judged([{ id: 'r1', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', evidence: 'no lock before the read' }]);
444
- expect(broken.open.map(i => [i.kind, i.class, i.issue, i.evidence])).toEqual([['rule', 'repo-rule', 'Every job must take the lock before its first read.', 'breaks a rule this repository wrote for itself (AGENTS.md): no lock before the read']]);
445
- expect(judged([{ id: 'r1', status: 'broken', file: 'src/job.ts', line: 2 }])).toMatchObject({ open: [], unverified: [expect.objectContaining({ kind: 'rule' })] }); // no quote: not shown as a block
446
- expect(judged([{ id: 'r2', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;' }])).toMatchObject({ open: [], advisory: [expect.objectContaining({ class: 'repo-rule' })] }); // guidance: shown, never a block
447
- expect(judged([{ id: 'r1', status: 'followed' }, { id: 'r1', status: 'not-applicable' }])).toMatchObject({ open: [], notes: [], advisory: [], unverified: [] });
448
- const unknown = judged([{ id: 'made-up', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', rule: 'a rule the judge invented', requirement: true }]);
449
- expect(unknown.verdict.rules).toEqual([]); // an answer naming no served rule is dropped, whatever it claims
450
- expect(unknown.open).toEqual([]);
451
- });
452
- it("shows the same point found in several places as one item with every location; a judge item on a human point's lines folds into it, and human points never merge", () => {
453
- const scan = (file, line, quote) => ({ class: 'production-cost', file, line, issue: `the ${file.split('/').pop()} scan has no upper bound on updated_at`, input: 'a week of rows', consequence: 'rows read grow with time', quote });
454
- const verdict = { ...EMPTY, prior_points: [
455
- { point: 'the scan has no upper bound on updated_at', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 1, quote: 'export function job() {' },
456
- { point: 'bound the window again', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 1, quote: 'export function job() {' },
457
- ], findings: [scan('src/job.ts', 1, 'export function job() {'), scan('a.ts', 1, 'export const a = 1;'), { ...scan('src/job.ts', 2, 'return 1;'), class: 'correctness' }] };
458
- const { open } = account(verdict, undefined, checkoutVerifier(repo));
459
- expect(open.map(i => [i.kind, i.class, i.locations ?? []])).toEqual([
460
- // The judge's scan on the human's lines, in like words, is the human's point found again, and so is the same point said as another class on the next line.
461
- // The same scan in another file is not on the human's lines: its own item.
462
- ['prior', 'prior point', [{ file: 'src/job.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]], ['prior', 'prior point', []], ['finding', 'production-cost', []],
463
- ]);
464
- // On other lines than any human point: the same point in another file, and said as another class on the next line, is one item, every place.
465
- const apart = account({ ...verdict, prior_points: [] }, undefined, checkoutVerifier(repo));
466
- expect(apart.open.map(i => [i.kind, i.class, i.locations ?? []])).toEqual([['finding', 'production-cost', [{ file: 'a.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]]]);
467
- // On a human point's lines: a rule break in like words is that point found again; a different rule, or a finding in other
468
- // words, stays its own item, so fixing the human's point does not leave it for the next round.
469
- const human = { point: 'null guards on columns the query makes non-null are dead fallbacks', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;' };
470
- const onHuman = account({ ...EMPTY, prior_points: [human],
471
- findings: [{ class: 'correctness', file: 'src/job.ts', line: 3, issue: 'the retry re-sends the email', input: 'a timeout', consequence: 'two emails', quote: '}' }],
472
- rules: [
473
- { id: 'r1', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', rule: 'No dead fallbacks or null guards on non-null columns.', source: 'AGENTS.md', requirement: true },
474
- { id: 'r2', status: 'broken', file: 'src/job.ts', line: 3, quote: '}', rule: 'Import the JOBS_TABLE constant; do not inline the raw table name.', source: 'AGENTS.md', requirement: true },
475
- ] }, undefined, checkoutVerifier(repo));
476
- expect(onHuman.open.map(i => [i.kind, i.locations ?? []])).toEqual([['prior', [{ file: 'src/job.ts', line: 2 }]], ['rule', []], ['finding', []]]);
477
- expect(onHuman.open[1].issue).toContain('JOBS_TABLE');
478
- // A rule break and the finding it caused, on the same lines and in like words, are one item.
479
- const twice = account({ ...EMPTY, prior_points: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'the raw table name is inlined instead of the JOBS_TABLE constant', input: 'any run', consequence: 'a rename misses it', quote: 'return 1;' }],
480
- rules: [{ id: 'r', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', rule: 'Import the JOBS_TABLE constant; do not inline the raw table name again.', source: 'AGENTS.md', requirement: true }] }, undefined, checkoutVerifier(repo));
481
- expect(twice.open.map(i => i.class)).toEqual(['repo-rule']);
482
- expect(twice.open[0].locations).toEqual([{ file: 'src/job.ts', line: 2 }]);
483
- });
484
- it('keeps reads, scans, redundancy and merge impact as notes with stable ids, and answers non-blocking points in the reply', () => {
485
- const verdict = {
486
- ...EMPTY,
487
- prior_points: [{ point: 'nit: rename', severity: 'non-blocking', resolved: false }],
488
- reads: [{ file: 'src/q.ts', line: 10, read: 'select attempts', rules: [{ rule: 'flag off', known_before_read: true, applied_before_read: false }], narrower_source: 'attempt.updated_at', keys: [{ name: 'eventId', inputs: 'answered_at', stable_under_edit: false }], window_bounded: false, keyset: null }],
489
- scans: [{ file: 'src/s.ts', line: 3, function: 'later', outer: 'attempts', inner: 'answers', fix: 'index by attempt' }],
490
- redundant: [{ file: 'src/r.ts', line: 5, what: 'null guard', made_redundant_by: 'src/r.ts:2', removed: false }, { file: 'src/r.ts', line: 9, what: 'removed guard', removed: true }],
491
- merge_impact: [{ symbol: 'parseId', main_file: 'src/id.ts', call_site: 'src/link.ts:4', holds: false, why: 'band dropped' }],
492
- };
493
- const { open, notes, answerInReply } = account(verdict, undefined, () => true);
494
- expect(open).toEqual([]);
495
- expect(notes.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
496
- 'dead-code src/r.ts:5', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'correctness src/q.ts:10', 'production-cost src/s.ts:3', 'correctness src/link.ts:4',
497
- ]);
498
- expect(new Set(notes.map(i => i.id)).size).toBe(notes.length);
499
- expect(account(verdict, undefined, () => true).notes.map(i => i.id)).toEqual(notes.map(i => i.id)); // stable
500
- expect(answerInReply).toEqual([expect.objectContaining({ point: 'nit: rename' })]);
501
- });
502
- it('merges two verdicts: a point is resolved only when every reviewer resolves it', () => {
503
- const a = { ...EMPTY, reviewer: 'claude', prior_points: [{ point: 'Lock before read', severity: 'blocking', resolved: true }], resolved_previous: [{ id: 'x', evidence: 'e' }, { id: 'y', evidence: 'e' }] };
504
- const b = { ...EMPTY, reviewer: 'cursor', prior_points: [{ point: 'lock before read.', severity: 'blocking', resolved: false }], resolved_previous: [{ id: 'x', evidence: 'e' }] };
505
- const merged = mergeVerdicts([a, b]);
506
- expect(merged.prior_points).toEqual([expect.objectContaining({ resolved: false, reviewer: 'cursor' })]);
507
- expect(merged.resolved_previous.map(r => r.id)).toEqual(['x']);
508
- expect(merged.reviewers?.map(r => r.reviewer)).toEqual(['claude', 'cursor']);
509
- });
510
- it('carries a resolved human point into a delta verdict unless the new commits touch its evidence', () => {
511
- const previous = { ...EMPTY, prior_points: [{ point: 'lock before read', resolved: true, evidence: 'src/job.ts:2' }, { point: 'rename it', resolved: true, evidence: 'src/name.ts:9' }] };
512
- const fresh = { ...EMPTY, prior_points: [] };
513
- expect(carryResolved(fresh, previous, new Set(['src/name.ts'])).prior_points.map(p => p.point)).toEqual(['lock before read']);
514
- expect(carryResolved({ ...EMPTY, prior_points: [{ point: 'Lock before read', resolved: false }] }, previous, new Set()).prior_points).toHaveLength(2);
515
- });
516
- });
517
- describe('a panel of judges', () => {
518
- const panelConfig = (reviewer) => ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor', 'codex'], mode: 'full', panel: 'on', ...reviewer } } });
519
- const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' };
520
- const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', quote: 'export function job() {', consequence: 'a reader treats it as the contract' };
521
- const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', quote: 'export function job() {', consequence: '' };
522
- it("keeps Rigour's own key from every judge, and a key the team names from that judge, in reviews and cross-examinations alike", async () => {
523
- installFake(bins[0], 'codex');
524
- const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
525
- const reply = (name) => {
526
- const prompt = seen.prompts.at(-1) ?? '';
527
- if (prompt.includes('The other\nreviewer raised')) {
528
- const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
529
- return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
530
- }
531
- return JSON.stringify({ ...EMPTY, findings: name === 'codex' ? [LONE] : [] });
532
- };
533
- await runReviewer(repo, 'main', panelConfig({ judges: 3, judge_env: { codex: { unset: ['OPENAI_API_KEY'] } } }), fakes(reply, seen), () => undefined);
534
- const runs = seen.ran.map((command, i) => ({ judge: path.basename(command).replace(/\.(cmd|exe)$/, ''), unset: seen.unset?.[i] ?? [] }));
535
- expect(runs.length).toBe(5); // three reviews and two cross-examinations
536
- for (const run of runs)
537
- expect(run.unset).toContain('RIGOUR_API_KEY');
538
- expect(runs.filter(r => r.unset.includes('OPENAI_API_KEY')).map(r => r.judge)).toEqual(['codex']);
539
- });
540
- it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
541
- installFake(bins[0], 'codex');
542
- const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
543
- const reply = (name) => {
544
- const prompt = seen.prompts.at(-1) ?? '';
545
- if (prompt.includes('The other\nreviewer raised')) {
546
- const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
547
- return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
548
- }
549
- if (name === 'codex')
550
- return JSON.stringify({ ...EMPTY, findings: [LONE] });
551
- return JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK, OPINION] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }] });
552
- };
553
- const result = await runReviewer(repo, 'main', panelConfig({ judges: 3, cross_models: { claude: 'claude-haiku-4-5' } }), fakes(reply, seen), () => undefined);
554
- expect(result.reviewers).toEqual(['claude', 'cursor', 'codex']);
555
- expect(result.mode).toMatchObject({ asked: 'panel', ran: 'panel', source: 'team' });
556
- expect(result.items.map(i => [i.issue, i.reviewer])).toEqual([[LOCK.issue, 'claude+cursor']]);
557
- expect(result.dropped.map(i => i.issue)).toEqual([LONE.issue]);
558
- expect(result.notes.map(i => i.issue)).toEqual([OPINION.issue]);
559
- expect(seen.prompts).toHaveLength(5); // three blind reviews, then claude and cursor each cross-examine codex's lone finding once
560
- expect(result.panel?.find(d => d.item.issue === LONE.issue)).toMatchObject({ judges: ['codex'], calls: { codex: 'raised', claude: 'refute', cursor: 'refute' }, status: 'dropped' });
561
- expect(result.costUsd).toBe(3); // claude's review and claude's cross-examination
562
- const claudeCalls = (seen.args ?? []).filter((_, i) => path.basename(seen.ran[i]).startsWith('claude'));
563
- expect(claudeCalls.map(a => a.includes('claude-haiku-4-5'))).toEqual([false, true]); // the cheaper model for the cross-examination only
564
- });
565
- it('runs one judge and says why when only one vendor is installed, and is unavailable when the team requires the panel', async () => {
566
- const seen = seenNow();
567
- const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
568
- const fallback = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'] }), fakes(reply, seen), () => undefined);
569
- expect(fallback.mode).toMatchObject({ asked: 'panel', ran: 'single', degraded: expect.stringContaining('not installed: codex') });
570
- expect(fallback.items.map(i => i.issue)).toEqual([LOCK.issue]);
571
- const required = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'], panel: 'required' }), fakes(reply, seenNow()), () => undefined);
572
- expect(required.outcome).toBe('unavailable');
573
- expect(required.reason).toContain('rigour.yml requires two reviewers');
574
- });
575
- it('keeps to the daily caps: a review past the run cap is skipped, or unavailable when the team requires the reviewer', async () => {
576
- const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
577
- const skipped = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1 }), fakes(reply, seenNow()), () => undefined);
578
- expect(skipped.outcome).toBe('skipped');
579
- expect(skipped.reason).toContain('the daily run cap is reached: 0 of 1 agent runs used today in this repository, and this needs 2 more');
580
- const required = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1, panel: 'required' }), fakes(reply, seenNow()), () => undefined);
581
- expect(required.outcome).toBe('unavailable');
582
- });
583
- it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
584
- const seen = seenNow();
585
- const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', quote: 'export function job() {', consequence: 'a reader treats it as the contract' };
586
- const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
587
- // Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
588
- const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
589
- expect(seen.prompts).toHaveLength(2);
590
- expect(result.items.map(i => i.issue)).toEqual([LOCK.issue]);
591
- expect(result.panel?.find(d => d.item.issue === lone.issue)).toMatchObject({ status: 'disputed', note: expect.stringContaining('the daily run cap is reached') });
592
- // claude reported $1.50: a cost cap of $1 lets no new review start today.
593
- const capped = await runReviewer(repo, 'main', panelConfig({ max_usd_per_day: 1 }), fakes(reply, seenNow()), () => undefined, { force: true });
594
- expect(capped).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('the daily cost cap is reached: $1.50 of $1.00') });
595
- });
596
- it('escalates on risk: one judge for a change with no risky function and no human review', async () => {
597
- const seen = seenNow();
598
- const result = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined);
599
- expect(result.mode).toMatchObject({ asked: 'panel', ran: 'single', escalation: expect.stringContaining('no risky changed function') });
600
- expect(seen.prompts).toHaveLength(1);
601
- const full = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seenNow(), null), () => undefined, { full: true, force: true });
602
- expect(full.mode?.ran).toBe('panel'); // the --full hard stop always gets every judge
603
- });
604
- });
605
- describe('what the team already knows', () => {
606
- const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
607
- it('refuses a dismissal unless the team allows them: fix the code, or the reviewer', async () => {
608
- const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
609
- expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
610
- expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
611
- });
612
- it('a dismissed finding reaches the next judge as settled, and a re-worded repeat never blocks', async () => {
613
- fs.mkdirSync(path.join(repo, 'docs'));
614
- fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
615
- git('add', '-A');
616
- git('commit', '-qm', 'docs');
617
- const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' };
618
- const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
619
- expect(first.outcome).toBe('findings');
620
- expect(await dismissReviewerFinding(repo, 'abcdef0123', 'not one of ours', true)).toEqual({ error: 'no open reviewer finding abcdef0123 on feature: run `rigour review --reviewer` and copy the id it shows' });
621
- expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock one level up', true)).item?.issue).toBe('returns before the lock is taken');
622
- expect((await reviewStatus(repo, 'feature'))?.last?.open).toEqual([]); // not work any more, right away
623
- const seen = seenNow();
624
- // The same commit again: no new run; the stored decision is reused, with the dismissal applied.
625
- const reused = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), seenNow()), () => undefined);
626
- expect(reused).toMatchObject({ cached: true, outcome: 'passed' });
627
- expect(reused.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken']);
628
- // A fresh review: the judge is told it is settled, and a re-worded repeat does not block either.
629
- const again = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ ...finding, issue: 'returns before the lock is taken, so it races' }] }), seen), () => undefined, { force: true });
630
- expect(again.cached).toBe(false);
631
- expect(again.outcome).toBe('passed');
632
- expect(again.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken, so it races']);
633
- expect(seen.files['team-knowledge.md']).toContain('dismissed as not a bug by t@example.com: src/job.ts:2 [correctness] returns before the lock is taken (reason: the runner holds a lock one level up)');
634
- expect(seen.files['team-knowledge.md']).toContain('docs/jobs.md (names src/job.ts');
635
- const told = seenNow();
636
- await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), told), () => undefined, { force: true, checks: ['src/job.ts:1 Unused export `job`'] });
637
- expect(told.files['team-knowledge.md']).toContain("## Already found by Rigour's checks: they block on their own, so do not report them again\n- src/job.ts:1 Unused export `job`");
638
- });
639
- it('fails closed: a finding whose judge left out the consequence still blocks', async () => {
640
- const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {' }] }), seenNow()), () => undefined);
641
- expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
642
- expect(result.notes).toEqual([]);
643
- });
644
- });
645
- describe("a human's prior point", () => {
646
- const point = (over) => ({ point: 'keep a separate case for a visitor with no account', review: 'senior 2026-09-25T18:09:11Z', severity: 'blocking', resolved: false, evidence: 'tests/e2e/gate.ts:137', file: 'tests/e2e/gate.ts', line: 137, quote: 'expect(href).toMatch(/account_id=/)', ...over });
647
- const verdict = (p) => ({ ...EMPTY, prior_points: [p] });
648
- const approvals = [{ login: 'senior', at: '2026-09-28T15:12:53Z' }];
649
- it('blocks where the judge quotes the code that keeps it open and no one approved since', () => {
650
- const { open } = account(verdict(point({})), undefined, () => true, { approvals: [], inCheckout: () => undefined });
651
- expect(open.map(i => i.issue)).toEqual(['keep a separate case for a visitor with no account']);
652
- });
653
- it('is settled by its own reviewer approving after raising it: a note, never a block', () => {
654
- const { open, notes } = account(verdict(point({})), undefined, () => true, { approvals, inCheckout: () => undefined });
655
- expect(open).toEqual([]);
656
- expect(notes).toMatchObject([{ kind: 'prior', issue: 'keep a separate case for a visitor with no account', evidence: 'senior approved on 2026-09-28T15:12:53Z, after raising it: settled' }]);
657
- });
658
- it('is not settled by an approval before it, by another person, or when the judge names no reviewer', () => {
659
- const before = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'senior', at: '2026-09-20T00:00:00Z' }], inCheckout: () => undefined });
660
- const other = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'peer', at: '2026-09-28T15:12:53Z' }], inCheckout: () => undefined });
661
- const unnamed = account(verdict(point({ review: undefined })), undefined, () => true, { approvals, inCheckout: () => undefined });
662
- for (const result of [before, other, unnamed])
663
- expect(result.open).toHaveLength(1);
664
- const undated = account(verdict(point({ review: 'senior' })), undefined, () => true, { approvals, inCheckout: () => undefined });
665
- expect(undated.open).toEqual([]); // the reviewer named and approved: settled
666
- });
667
- it('that calls something missing is unverified when the checkout has it elsewhere', () => {
668
- const searched = [];
669
- const found = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: text => (searched.push(text), 'src/lib/Upsell.test.ts:128') });
670
- expect(searched).toEqual(['origin=native&returnTo=']);
671
- expect(found.open).toEqual([]);
672
- expect(found.unverified).toMatchObject([{ kind: 'prior', evidence: 'says "origin=native&returnTo=" is missing, and the checkout has it at src/lib/Upsell.test.ts:128' }]);
673
- const missing = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: () => undefined });
674
- expect(missing.open).toHaveLength(1); // searched, not there: the point stands on its quote
675
- });
676
- });
677
- describe('searching the checkout for what a point calls missing', () => {
678
- it('finds the first line of the text anywhere in the tracked tree, and nothing untracked', () => {
679
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-search-'));
680
- execFileSync('git', ['-C', dir, 'init', '-q']);
681
- fs.mkdirSync(path.join(dir, 'src'));
682
- fs.writeFileSync(path.join(dir, 'src', 'a.test.ts'), 'it("no account", () => {\n expect(href).toBe("/checkout?origin=native");\n});\n');
683
- fs.writeFileSync(path.join(dir, 'untracked.ts'), 'const ghost = 1;\n');
684
- execFileSync('git', ['-C', dir, 'add', 'src']);
685
- const search = checkoutSearch(dir);
686
- expect(search(' expect(href).toBe("/checkout?origin=native");\n more')).toBe('src/a.test.ts:2');
687
- expect(search('const ghost = 1;')).toBeUndefined();
688
- expect(search(' \n')).toBeUndefined();
689
- });
690
- });
691
- describe('a block sits on a line the change touched', () => {
692
- const diff = [
693
- 'diff --git a/src/player.ts b/src/player.ts', '--- a/src/player.ts', '+++ b/src/player.ts',
694
- '@@ -10,4 +10,5 @@ function resume() {', ' const a = 1;', '- old();', '+ report(a);', '+ report(b);', ' return a;', ' }',
695
- 'diff --git a/src/gone.ts b/src/gone.ts', '--- a/src/gone.ts', '+++ /dev/null', '@@ -1,2 +0,0 @@', '-export const x = 1;', '-export const y = 2;',
696
- 'diff --git a/src/new.ts b/src/new.ts', '--- /dev/null', '+++ b/src/new.ts', '@@ -0,0 +1,2 @@', '+export const z = 1;', '+export const w = 2;', '',
697
- ].join('\n');
698
- const changed = changedLinesOf(diff);
699
- const rule = (file, line) => ({ ...EMPTY, prior_points: [], rules: [{ id: 'r1', status: 'broken', rule: 'wrap every navigation target in resolve()', source: 'AGENTS.md', requirement: true, file, line, quote: 'preloadCode(target)' }] });
700
- const checks = { approvals: [], inCheckout: () => undefined, changed };
701
- it('reads the touched lines of a diff: added lines, the place of a deletion, nothing for a deleted file', () => {
702
- expect([...changed.get('src/player.ts')].sort((a, b) => a - b)).toEqual([11, 12]); // the deletion's place, then the two added lines (11 is both)
703
- expect([...changed.get('src/new.ts')]).toEqual([1, 2]);
704
- expect(changed.has('src/gone.ts')).toBe(false);
705
- });
706
- it('blocks a verified rule break near a touched line, and notes one on lines the change did not touch, or with no line', () => {
707
- expect(account(rule('src/player.ts', 14), undefined, () => true, checks).open).toHaveLength(1); // within the window of line 12
708
- const far = account(rule('src/player.ts', 1819), undefined, () => true, checks);
709
- expect(far.open).toEqual([]);
710
- expect(far.notes).toMatchObject([{ kind: 'rule', line: 1819, evidence: expect.stringContaining('on a line this change did not touch: what the code already had, never a block on this change') }]);
711
- const unplaced = account(rule('src/player.ts', undefined), undefined, () => true, checks);
712
- expect(unplaced.open).toEqual([]);
713
- expect(unplaced.notes[0].evidence).toContain('names no line');
714
- expect(account(rule('src/other.ts', 3), undefined, () => true, checks).open).toEqual([]); // a file the change did not touch at all
715
- });
716
- it("leaves a human's point, and every item when the diff is unknown, as before", () => {
717
- const point = { ...EMPTY, prior_points: [{ point: 'wrap the target', review: 'senior 2026-10-01', severity: 'blocking', resolved: false, file: 'src/player.ts', line: 1819, quote: 'preloadCode(target)' }] };
718
- expect(account(point, undefined, () => true, checks).open).toHaveLength(1);
719
- expect(account(rule('src/player.ts', 1819), undefined, () => true, { approvals: [], inCheckout: () => undefined }).open).toHaveLength(1);
720
- });
721
- });
722
- describe("a human's should-fix point", () => {
723
- it('is shown with its quote and never blocks, like a should-fix finding', () => {
724
- const verdict = { ...EMPTY, prior_points: [{ point: 'a reopened deck reads as a return every day', review: 'senior 2026-10-01', severity: 'should-fix', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;' }] };
725
- const { open, advisory, unverified } = account(verdict, undefined, checkoutVerifier(repo));
726
- expect(open).toEqual([]);
727
- expect(advisory.map(i => [i.kind, i.issue])).toEqual([['prior', 'a reopened deck reads as a return every day']]);
728
- expect(unverified).toEqual([]);
729
- const unplaced = account({ ...verdict, prior_points: [{ ...verdict.prior_points[0], quote: 'not in the file' }] }, undefined, checkoutVerifier(repo));
730
- expect(unplaced.advisory).toEqual([]); // a should-fix the judge cannot show is not worth a person's time
731
- expect(unplaced.unverified).toHaveLength(1);
732
- });
733
- });
734
- describe("the reviewer's own severity label", () => {
735
- const at = '2026-10-01T10:00:00Z';
736
- const labels = [
737
- { login: 'senior', at, severity: 'blocking', text: 'The kill switch is read after every query: check it first.' },
738
- { login: 'senior', at, severity: 'should-fix', text: 'The comment on the window still says daily.' },
739
- ];
740
- const point = (over) => ({ ...EMPTY, prior_points: [{ point: 'the kill switch is read after every query', review: 'senior 2026-10-01T10:00:00Z', severity: 'should-fix', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;', ...over }] });
741
- it('wins over the judge: a blocker the judge read as a should-fix blocks, and the disagreement is said', () => {
742
- const { open, advisory } = account(point({}), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
743
- expect(advisory).toEqual([]);
744
- expect(open).toMatchObject([{ kind: 'prior', evidence: 'the review labels it blocking; the judge read should-fix' }]);
745
- });
746
- it('matches the review by its date however the judge writes the time, and falls back to the reviewer\'s latest labelled review', () => {
747
- const later = { login: 'senior', at: '2026-10-03T09:00:00Z', severity: 'should-fix', text: 'The kill switch is read after every query: check it first.' };
748
- for (const review of ['senior 2026-10-01', 'senior 2026-10-01 10:00', 'senior']) {
749
- const { open, labels: counted } = account(point({ review }), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
750
- expect(open).toHaveLength(1);
751
- expect(counted).toEqual({ served: 2, taken: 1, disagreed: 1 });
752
- }
753
- // No review of that reviewer on the judge's date: the reviewer's latest labelled review decides (here, a should-fix).
754
- const { open, advisory } = account(point({ review: 'senior 2026-09-30', severity: 'blocking' }), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels: [...labels, later] });
755
- expect([open.length, advisory.length]).toEqual([0, 1]);
756
- });
757
- it('applies only to the same reviewer, and only to a point that reads like the labelled line; the counts say so', () => {
758
- for (const over of [{ review: 'peer 2026-10-01T10:00:00Z' }, { point: 'the email retry sends twice' }]) {
759
- const { open, advisory, labels: counted } = account(point(over), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
760
- expect([open.length, advisory.length]).toEqual([0, 1]); // the judge's should-fix stands
761
- expect(counted).toEqual({ served: 2, taken: 0, disagreed: 0 });
762
- }
763
- });
764
- });
765
- describe('the judge Rigour launches', () => {
766
- it('runs claude with every memory file switched off, and records the isolation as unverified below the version it was verified in', async () => {
767
- const seen = seenNow();
768
- const result = await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
769
- expect(result.outcome).toBe('passed');
770
- const claude = seen.ran.findIndex(command => path.basename(command).startsWith('claude'));
771
- expect(seen.env?.[claude]).toEqual({ CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' });
772
- // The fake reports version 1.0.0, older than the one the switches were verified in: the record says so.
773
- expect(result.record?.judges.map(j => j.outside_repo)).toEqual(['claude 1.0.0: memory isolation unverified (needs 2.1.285 or later)']);
774
- expect(recordLines(result.record).join('\n')).toContain('[claude 1.0.0: memory isolation unverified (needs 2.1.285 or later)]');
775
- const current = seenNow();
776
- // The installed fake is claude on Unix and claude.cmd on Windows: name both.
777
- current.versions = { [path.join(bins[0], 'claude')]: '2.1.285 (Claude Code)', [path.join(bins[0], 'claude.cmd')]: '2.1.285 (Claude Code)' };
778
- const verified = await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), current, null), () => undefined, { trigger: 'review', force: true });
779
- expect(verified.record?.judges.map(j => j.outside_repo)).toEqual([undefined]);
780
- });
781
- });
782
- describe("the review on the task's thread", () => {
783
- it('appends each review of a branch to its task, and never a backtest replaying history', async () => {
784
- const seen = seenNow();
785
- await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
786
- const thread = readThread(repo, 'feature');
787
- expect(thread?.events.map(e => [e.kind, e.trigger, e.outcome, e.blocking])).toEqual([['review', 'review', 'passed', 0]]);
788
- expect(thread?.events[0].integrity).toEqual(expect.any(String));
789
- await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seenNow()), () => undefined, { pr: 42, reviewsBefore: '2026-10-03', force: true });
790
- expect(readThread(repo, 'feature')?.events).toHaveLength(1);
791
- });
792
- });