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