@rigour-labs/core 6.7.2 → 6.7.4

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 (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +122 -20
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +149 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -8,7 +8,7 @@ import { reviewerBlocks, runReviewer } from './reviewer.js';
8
8
  import { dismissReviewerFinding } from './reviewer/context.js';
9
9
  import { reviewStatus } from './reviewer/background.js';
10
10
  import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
11
- import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
11
+ import { account, carryResolved, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
12
12
  let repo;
13
13
  const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
14
14
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
@@ -49,6 +49,7 @@ function fakes(answer, seen, pr = PR) {
49
49
  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' };
50
50
  seen.ran.push(command);
51
51
  (seen.args ??= []).push(args);
52
+ (seen.unset ??= []).push(options.unset);
52
53
  const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
53
54
  const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
54
55
  seen.prompts.push(prompt);
@@ -99,7 +100,7 @@ afterEach(() => {
99
100
  describe('the reviewer', () => {
100
101
  it('works from every human review with inline comments and the description, written to files, and blocks on an open point', async () => {
101
102
  const seen = seenNow();
102
- const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2' }] });
103
+ 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() {' }] });
103
104
  const result = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
104
105
  expect(seen.files['previous-reviews.md']).toContain('Review by senior');
105
106
  expect(seen.files['previous-reviews.md']).toContain('- 2026-10-03 src/job.ts:12: Check the lock before the first read.');
@@ -115,7 +116,7 @@ describe('the reviewer', () => {
115
116
  });
116
117
  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 () => {
117
118
  const seen = seenNow();
118
- const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows', why: 'x' }] });
119
+ 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' }] });
119
120
  const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
120
121
  expect(first.items).toHaveLength(1);
121
122
  const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
@@ -138,7 +139,7 @@ describe('the reviewer', () => {
138
139
  });
139
140
  it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
140
141
  const seen = seenNow();
141
- 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', 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);
142
+ 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);
142
143
  expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
143
144
  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
144
145
  const id = first.items[0].id;
@@ -170,6 +171,32 @@ describe('the reviewer', () => {
170
171
  expect(seen.files['previous-reviews.md']).toContain('Review by senior');
171
172
  expect(shown).toMatchObject({ outcome: 'unavailable', reason: 'claude did not report on the human reviews' });
172
173
  });
174
+ it('for a backtest, gives the description as it read at the review, never a later edit', async () => {
175
+ const versions = { lastEditedAt: '2026-10-06T00:00:00Z', body: 'today: refunds are issued by the nightly job', userContentEdits: { totalCount: 3, nodes: [
176
+ { editedAt: '2026-10-06T00:00:00Z', diff: 'today: refunds are issued by the nightly job' },
177
+ { editedAt: '2026-10-02T00:00:00Z', diff: 'then: refunds are issued on request' },
178
+ { editedAt: '2026-09-30T00:00:00Z', diff: 'first draft' },
179
+ ] } };
180
+ const withEdits = (answer, seen, graphql) => {
181
+ const base = fakes(answer, seen);
182
+ return async (command, args, options) => command === 'gh' && args[0] === 'api' && args[1] === 'graphql'
183
+ ? { exitCode: graphql ? 0 : 1, stdout: JSON.stringify({ data: { repository: { pullRequest: graphql } } }), stderr: '' }
184
+ : base(command, args, options);
185
+ };
186
+ const reply = () => JSON.stringify({ ...EMPTY, prior_points: [] });
187
+ const seen = seenNow();
188
+ await runReviewer(repo, 'main', config, withEdits(reply, seen, versions), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
189
+ expect(seen.files['pr-description.md']).toBe('then: refunds are issued on request');
190
+ await runReviewer(repo, 'main', config, withEdits(reply, seen, null), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
191
+ expect(seen.files['pr-description.md']).toContain('could not be recovered');
192
+ });
193
+ it('blind, reviews the commit alone and never asks GitHub', async () => {
194
+ const seen = seenNow();
195
+ const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { blind: true, trigger: 'backtest', force: true });
196
+ expect(result.outcome).toBe('passed');
197
+ expect(seen.ghArgs).toEqual([]);
198
+ expect(seen.files['previous-reviews.md']).toBe('none\n');
199
+ });
173
200
  it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
174
201
  const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
175
202
  expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('claude: no answer (exit 1)') });
@@ -182,9 +209,51 @@ describe('the reviewer', () => {
182
209
  for (const result of [crashed, prose])
183
210
  expect(reviewerBlocks(result)).toBe(true);
184
211
  });
212
+ it('keeps where each judge run spent its tokens and what it read, labelled, in the local verdict', async () => {
213
+ const seen = seenNow();
214
+ const base = fakes(() => JSON.stringify(EMPTY), seen);
215
+ const streaming = async (command, args, options) => {
216
+ if (path.basename(command).replace(/\.(cmd|exe)$/, '') !== 'claude' || args[0] === '--version')
217
+ return base(command, args, options); // claude.cmd on Windows
218
+ const prompt = args[args.indexOf('-p') + 1];
219
+ const diff = /(\S+full\.diff)/.exec(prompt)[1];
220
+ 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 }] } });
221
+ const events = [
222
+ 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') }),
223
+ call('4', 'Bash', { command: 'git log -3' }), call('5', 'Grep', { pattern: 'job' }),
224
+ { type: 'result', result: JSON.stringify(EMPTY), total_cost_usd: 0.2, usage: { input_tokens: 5, output_tokens: 25 } },
225
+ ];
226
+ return { exitCode: 0, stdout: events.map(e => JSON.stringify(e)).join('\n'), stderr: '' };
227
+ };
228
+ await runReviewer(repo, 'main', config, streaming, () => undefined, { force: true });
229
+ const store = path.join(repo, '.git', 'rigour-reviewer');
230
+ 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];
231
+ expect(verdict.reviewers[0].trace).toMatchObject({ turns: 5, usage: { input: 5, cacheRead: 500, cacheWrite: 50, output: 25 } });
232
+ expect(verdict.reviewers[0].trace.calls.map((c) => c.category)).toEqual(['rigour-input', 'changed-file', 'other-file', 'git', 'search']);
233
+ });
234
+ 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 () => {
235
+ const seen = seenNow();
236
+ let calls = 0;
237
+ const slipOnce = await runReviewer(repo, 'main', config, fakes(() => (++calls === 1 ? '{"prior_points":[], "findings":[{"class"' : JSON.stringify(EMPTY)), seen), () => undefined, { force: true });
238
+ expect(slipOnce.outcome).toBe('passed');
239
+ expect(seen.prompts).toHaveLength(2);
240
+ const twice = seenNow();
241
+ const slipTwice = await runReviewer(repo, 'main', config, fakes(() => 'not json', twice), () => undefined, { force: true });
242
+ expect(slipTwice).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
243
+ expect(twice.prompts).toHaveLength(2); // once more, never a loop
244
+ });
245
+ it('says what was asked and that nothing ran when a review ends early, with why a judge is missing', async () => {
246
+ const seen = { ...seenNow(), installed: ['claude'] }; // cursor is listed but not installed
247
+ const unreadable = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'gh auth login required' } : fakes(() => '', seen)(command, args, options);
248
+ const result = await runReviewer(repo, 'main', config, unreadable, () => undefined, { choice: { mode: 'full', panel: true } });
249
+ expect(result).toMatchObject({ outcome: 'unavailable', reviewers: ['claude'] });
250
+ expect(result.mode).toMatchObject({ asked: 'panel', ran: 'none', source: 'flag', degraded: expect.stringContaining('not installed: cursor-agent') });
251
+ const early = await runReviewer(repo, 'main', config, fakes(() => '', { ...seenNow(), installed: [] }), () => undefined, { choice: { mode: 'full', panel: true } });
252
+ expect(early).toMatchObject({ outcome: 'unavailable', mode: { asked: 'panel', ran: 'none', source: 'flag' } }); // before any judge was found
253
+ });
185
254
  it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
186
255
  const seen = seenNow();
187
- const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows' }] : [] });
256
+ 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;' }] : [] });
188
257
  const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
189
258
  expect(result.reviewers).toEqual(['claude', 'cursor']);
190
259
  expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
@@ -221,7 +290,72 @@ describe('verdicts', () => {
221
290
  expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
222
291
  expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
223
292
  });
224
- it('turns reads, scans, redundancy and merge impact into open items with stable ids, and answers non-blocking points in the reply', () => {
293
+ it('keeps the journey, sibling parity and claims as working notes, never blocks, and reads a verdict cached before they existed', () => {
294
+ const verdict = {
295
+ ...EMPTY,
296
+ journey: [
297
+ { 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 }] },
298
+ { file: 'src/w.ts', line: 9, what: 'caches the token', cleared_by: 'ttl', retry_safe: true, overlap_safe: true, can_move_back: null },
299
+ ],
300
+ siblings: [
301
+ { changed: 'src/a/run.ts:3', sibling: 'src/b/run.ts:7', needs_same_change: true, has_it: false, why: 'same lock' },
302
+ { changed: 'src/a/run.ts:3', sibling: 'src/c/run.ts:2', needs_same_change: true, has_it: true },
303
+ { changed: 'src/a/run.ts:3', sibling: 'src/d/run.ts:2', needs_same_change: false, has_it: false },
304
+ ],
305
+ claims: [
306
+ { source: 'description', claim: 'at worst one email', file: 'src/w.ts', line: 12, holds: false, evidence: 'loop sends per row' },
307
+ { source: 'comment', claim: 'runs daily', file: 'src/cron.ts', line: 1, holds: true },
308
+ ],
309
+ };
310
+ const { open, notes } = account(verdict, undefined, () => true);
311
+ expect(open).toEqual([]);
312
+ expect(notes.map(i => `${i.kind} ${i.class} ${i.file}:${i.line}`)).toEqual([
313
+ 'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4',
314
+ 'sibling correctness src/b/run.ts:7', 'claim stale-claim src/w.ts:12',
315
+ ]);
316
+ expect(notes[3].issue).toContain('needs the same change as src/a/run.ts:3: same lock');
317
+ const cached = { ...EMPTY };
318
+ delete cached.journey;
319
+ delete cached.siblings;
320
+ delete cached.claims;
321
+ expect(account(cached, undefined, () => true).open).toEqual([]);
322
+ expect(mergeVerdicts([cached, { ...verdict, reviewer: 'codex' }]).claims).toHaveLength(2);
323
+ });
324
+ 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', () => {
325
+ const verify = checkoutVerifier(repo);
326
+ 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);
327
+ expect(at(' return 1;').open).toHaveLength(1);
328
+ expect(at('return 1;').open).toHaveLength(1); // whitespace aside
329
+ expect(at('return 1;', 9)).toMatchObject({ open: [], unverified: [expect.objectContaining({ issue: 'returns before the lock' })] }); // past the end
330
+ expect(at('return 99;')).toMatchObject({ open: [], unverified: [expect.anything()] }); // not in the file
331
+ expect(at()).toMatchObject({ open: [], unverified: [expect.anything()] }); // no quote at all
332
+ const old = { id: 'r1', kind: 'read', class: 'production-cost', file: 'src/q.ts', line: 3, issue: 'window not bounded' };
333
+ const carried = account({ ...EMPTY, prior_points: [] }, [old], verify);
334
+ expect(carried).toMatchObject({ open: [], notes: [expect.objectContaining({ id: 'r1' })] });
335
+ });
336
+ it('shows a team lesson the change repeats as a note, never a block on its own', () => {
337
+ const verdict = { ...EMPTY, lessons: [
338
+ { lesson: 'Regenerate the API client after changing the schema.', applies: true, file: 'src/schema.ts', line: 3, evidence: 'schema.ts changed, client not' },
339
+ { lesson: 'Paginate with keyset.', applies: false, file: 'src/scan.ts', line: 9 },
340
+ ] };
341
+ const { open, notes } = account(verdict, undefined, () => true);
342
+ expect(open).toEqual([]);
343
+ 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.']]);
344
+ });
345
+ 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', () => {
346
+ const verify = checkoutVerifier(repo);
347
+ const decide = (v) => account({ ...EMPTY, prior_points: [], ...v }, undefined, verify);
348
+ const open = { point: 'take the lock before the first read', severity: 'blocking', resolved: false };
349
+ expect(decide({ prior_points: [{ ...open, file: 'src/job.ts', line: 2, quote: 'return 1;' }] }).open).toHaveLength(1);
350
+ expect(decide({ prior_points: [open] })).toMatchObject({ open: [], unverified: [expect.objectContaining({ kind: 'prior' })] }); // a later commit may have done it: no quote, no block
351
+ 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;' };
352
+ expect(decide({ findings: [{ ...finding, absent: 'return 1' }] })).toMatchObject({ open: [], unverified: [expect.anything()] }); // "missing", but the file has it
353
+ expect(decide({ findings: [{ ...finding, absent: 'conn.close(' }] }).open).toHaveLength(1);
354
+ expect(decide({ findings: [{ ...finding, severity: 'should' }] })).toMatchObject({ open: [], notes: [expect.anything()] });
355
+ const accepted = { point: 'job never closes the connection after the read', severity: 'non-blocking', resolved: false };
356
+ expect(decide({ prior_points: [accepted], findings: [finding] })).toMatchObject({ open: [], notes: [expect.anything()] }); // a human raised it and accepted it
357
+ });
358
+ it('keeps reads, scans, redundancy and merge impact as notes with stable ids, and answers non-blocking points in the reply', () => {
225
359
  const verdict = {
226
360
  ...EMPTY,
227
361
  prior_points: [{ point: 'nit: rename', severity: 'non-blocking', resolved: false }],
@@ -230,12 +364,13 @@ describe('verdicts', () => {
230
364
  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 }],
231
365
  merge_impact: [{ symbol: 'parseId', main_file: 'src/id.ts', call_site: 'src/link.ts:4', holds: false, why: 'band dropped' }],
232
366
  };
233
- const { open, answerInReply } = account(verdict, undefined, () => true);
234
- expect(open.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
367
+ const { open, notes, answerInReply } = account(verdict, undefined, () => true);
368
+ expect(open).toEqual([]);
369
+ expect(notes.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
235
370
  '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',
236
371
  ]);
237
- expect(new Set(open.map(i => i.id)).size).toBe(open.length);
238
- expect(account(verdict, undefined, () => true).open.map(i => i.id)).toEqual(open.map(i => i.id)); // stable
372
+ expect(new Set(notes.map(i => i.id)).size).toBe(notes.length);
373
+ expect(account(verdict, undefined, () => true).notes.map(i => i.id)).toEqual(notes.map(i => i.id)); // stable
239
374
  expect(answerInReply).toEqual([expect.objectContaining({ point: 'nit: rename' })]);
240
375
  });
241
376
  it('merges two verdicts: a point is resolved only when every reviewer resolves it', () => {
@@ -255,9 +390,27 @@ describe('verdicts', () => {
255
390
  });
256
391
  describe('a panel of judges', () => {
257
392
  const panelConfig = (reviewer) => ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor', 'codex'], mode: 'full', panel: 'on', ...reviewer } } });
258
- const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
259
- const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
260
- const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', consequence: '' };
393
+ 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' };
394
+ 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' };
395
+ const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', quote: 'export function job() {', consequence: '' };
396
+ 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 () => {
397
+ installFake(bins[0], 'codex');
398
+ const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
399
+ const reply = (name) => {
400
+ const prompt = seen.prompts.at(-1) ?? '';
401
+ if (prompt.includes('The other\nreviewer raised')) {
402
+ const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
403
+ return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
404
+ }
405
+ return JSON.stringify({ ...EMPTY, findings: name === 'codex' ? [LONE] : [] });
406
+ };
407
+ await runReviewer(repo, 'main', panelConfig({ judges: 3, judge_env: { codex: { unset: ['OPENAI_API_KEY'] } } }), fakes(reply, seen), () => undefined);
408
+ const runs = seen.ran.map((command, i) => ({ judge: path.basename(command).replace(/\.(cmd|exe)$/, ''), unset: seen.unset?.[i] ?? [] }));
409
+ expect(runs.length).toBe(5); // three reviews and two cross-examinations
410
+ for (const run of runs)
411
+ expect(run.unset).toContain('RIGOUR_API_KEY');
412
+ expect(runs.filter(r => r.unset.includes('OPENAI_API_KEY')).map(r => r.judge)).toEqual(['codex']);
413
+ });
261
414
  it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
262
415
  installFake(bins[0], 'codex');
263
416
  const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
@@ -303,7 +456,7 @@ describe('a panel of judges', () => {
303
456
  });
304
457
  it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
305
458
  const seen = seenNow();
306
- const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
459
+ 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' };
307
460
  const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
308
461
  // Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
309
462
  const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
@@ -326,7 +479,7 @@ describe('a panel of judges', () => {
326
479
  describe('what the team already knows', () => {
327
480
  const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
328
481
  it('refuses a dismissal unless the team allows them: fix the code, or the reviewer', async () => {
329
- 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', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
482
+ 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);
330
483
  expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
331
484
  expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
332
485
  });
@@ -335,7 +488,7 @@ describe('what the team already knows', () => {
335
488
  fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
336
489
  git('add', '-A');
337
490
  git('commit', '-qm', 'docs');
338
- const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
491
+ 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' };
339
492
  const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
340
493
  expect(first.outcome).toBe('findings');
341
494
  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' });
@@ -358,7 +511,7 @@ describe('what the team already knows', () => {
358
511
  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`");
359
512
  });
360
513
  it('fails closed: a finding whose judge left out the consequence still blocks', async () => {
361
- 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' }] }), seenNow()), () => undefined);
514
+ 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);
362
515
  expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
363
516
  expect(result.notes).toEqual([]);
364
517
  });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Inputs that never vary in production, so the code that handles other values is dead:
3
+ *
4
+ * constant-member a property every production object of the type sets to the same literal
5
+ * (`count: 0` in each), while code branches on it: the other branches never run.
6
+ * constant-argument a parameter every production call passes the same literal, while the body
7
+ * branches on it: the other branch never runs outside tests.
8
+ *
9
+ * Tests are not production: a test that passes other values is exactly the sign the generality
10
+ * exists only for them. Scoped like the other typed checks: the change declared the member or the
11
+ * function, or wrote every production host or call.
12
+ */
13
+ import type * as TS from 'typescript';
14
+ import type { TypedProgram } from './program.js';
15
+ export interface DeclaredType {
16
+ node: TS.InterfaceDeclaration | TS.TypeAliasDeclaration;
17
+ name: string;
18
+ members: TS.PropertySignature[];
19
+ }
20
+ export interface ConstantHelpers {
21
+ hostsOf: (t: DeclaredType & {
22
+ sym: TS.Symbol;
23
+ type: TS.Type;
24
+ }) => TS.ObjectLiteralExpression[];
25
+ touched: (node: TS.Node) => boolean;
26
+ spanTouched: (node: TS.Node) => boolean;
27
+ at: (node: TS.Node) => string;
28
+ rel: (sf: TS.SourceFile) => string;
29
+ report: (id: string, title: string, node: TS.Node, details: string, hint: string) => void;
30
+ walk: (node: TS.Node, fn: (n: TS.Node) => void) => void;
31
+ }
32
+ export declare function constantInputs(typed: TypedProgram, changedSources: TS.SourceFile[], declared: Array<DeclaredType & {
33
+ sym: TS.Symbol;
34
+ type: TS.Type;
35
+ }>, h: ConstantHelpers): void;
@@ -0,0 +1,168 @@
1
+ import { isTestFile } from '../test-files.js';
2
+ export function constantInputs(typed, changedSources, declared, h) {
3
+ constantMembers(typed, declared, h);
4
+ constantArguments(typed, changedSources, h);
5
+ }
6
+ /** A literal's value as text (`0`, `'a'`, `true`, `null`), or undefined for anything computed. */
7
+ function literal(ts, node) {
8
+ if (!node)
9
+ return undefined;
10
+ if (ts.isNumericLiteral(node) || ts.isStringLiteralLike(node))
11
+ return `${ts.isNumericLiteral(node) ? '' : "'"}${node.text}${ts.isNumericLiteral(node) ? '' : "'"}`;
12
+ if (node.kind === ts.SyntaxKind.TrueKeyword)
13
+ return 'true';
14
+ if (node.kind === ts.SyntaxKind.FalseKeyword)
15
+ return 'false';
16
+ if (node.kind === ts.SyntaxKind.NullKeyword)
17
+ return 'null';
18
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand))
19
+ return `-${node.operand.text}`;
20
+ return undefined;
21
+ }
22
+ /** The access is tested: an `if`/ternary/`while` condition, a comparison, `!`, `&&`/`||`, or a `switch`. */
23
+ function inCondition(ts, node) {
24
+ for (let n = node, p = node.parent; p; n = p, p = p.parent) {
25
+ if ((ts.isIfStatement(p) || ts.isWhileStatement(p) || ts.isConditionalExpression(p)) && p.expression === n)
26
+ return true;
27
+ if (ts.isConditionalExpression(p) && p.condition === n)
28
+ return true;
29
+ if (ts.isSwitchStatement(p) && p.expression === n)
30
+ return true;
31
+ if (ts.isBinaryExpression(p) && [ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.LessThanToken, ts.SyntaxKind.GreaterThanEqualsToken, ts.SyntaxKind.LessThanEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(p.operatorToken.kind)) {
32
+ if (ts.isIfStatement(p.parent) || ts.isConditionalExpression(p.parent) || ts.isBinaryExpression(p.parent) || ts.isPrefixUnaryExpression(p.parent) || ts.isReturnStatement(p.parent))
33
+ return true;
34
+ continue;
35
+ }
36
+ if (ts.isPrefixUnaryExpression(p) && p.operator === ts.SyntaxKind.ExclamationToken)
37
+ continue;
38
+ if (ts.isParenthesizedExpression(p))
39
+ continue;
40
+ return false;
41
+ }
42
+ return false;
43
+ }
44
+ function constantMembers(typed, declared, h) {
45
+ const { ts, checker } = typed;
46
+ for (const t of declared) {
47
+ const hosts = h.hostsOf(t).filter(host => !isTestFile(h.rel(host.getSourceFile())));
48
+ if (hosts.length < 2 || hosts.some(host => host.properties.some(p => ts.isSpreadAssignment(p))))
49
+ continue;
50
+ for (const m of t.members) {
51
+ const name = m.name.getText();
52
+ const values = hosts.map(host => {
53
+ const p = host.properties.find(q => q.name && ts.isIdentifier(q.name) && q.name.text === name);
54
+ return p && ts.isPropertyAssignment(p) ? literal(ts, p.initializer) : undefined;
55
+ });
56
+ if (values.some(v => v === undefined) || new Set(values).size !== 1)
57
+ continue;
58
+ const anchor = h.touched(m) ? m : hosts.every(h.spanTouched) ? hosts.find(h.spanTouched) : undefined;
59
+ if (!anchor)
60
+ continue;
61
+ const sym = checker.getPropertyOfType(t.type, name);
62
+ const tested = sym && findReads(typed, sym, h).find(read => inCondition(ts, read));
63
+ if (!tested)
64
+ continue;
65
+ h.report('constant-member', 'A member every production object sets to the same value', anchor, `\`${t.name}.${name}\` is ${values[0]} in all ${hosts.length} production objects of the type (e.g. ${h.at(hosts[0])}), yet ${h.at(tested)} branches on it: the other branches never run outside tests.`, `Delete the branches and the member, or pass the real value where the objects are built.`);
66
+ }
67
+ }
68
+ }
69
+ /** Every non-test read of the property symbol. */
70
+ function findReads(typed, sym, h) {
71
+ const { ts, checker } = typed;
72
+ const decl = sym.declarations?.[0];
73
+ const reads = [];
74
+ for (const sf of typed.appSources) {
75
+ if (isTestFile(h.rel(sf)) || !sf.text.includes(sym.name))
76
+ continue;
77
+ h.walk(sf, node => {
78
+ if (!ts.isPropertyAccessExpression(node) || node.name.text !== sym.name)
79
+ return;
80
+ const s = checker.getSymbolAtLocation(node.name);
81
+ if (s && (s === sym || s.declarations?.[0] === decl))
82
+ reads.push(node);
83
+ });
84
+ }
85
+ return reads;
86
+ }
87
+ function constantArguments(typed, changedSources, h) {
88
+ const { ts, checker } = typed;
89
+ // Functions the change declared, by declaration node.
90
+ const functions = [];
91
+ for (const sf of changedSources) {
92
+ h.walk(sf, node => {
93
+ if (ts.isFunctionDeclaration(node) && node.name && node.body && node.parameters.length)
94
+ functions.push(node);
95
+ else if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) && node.parameters.length)
96
+ functions.push(node);
97
+ });
98
+ }
99
+ if (functions.length === 0)
100
+ return;
101
+ const wanted = new Set(functions);
102
+ const calls = new Map();
103
+ // Tests included: a test passing another value is the evidence a production-only constant is a test seam.
104
+ for (const sf of typed.program.getSourceFiles().filter(f => !f.isDeclarationFile && !f.fileName.includes('/node_modules/'))) {
105
+ const test = isTestFile(h.rel(sf));
106
+ h.walk(sf, node => {
107
+ if (!ts.isCallExpression(node))
108
+ return;
109
+ const decl = checker.getResolvedSignature(node)?.declaration;
110
+ if (!decl || !wanted.has(decl))
111
+ return;
112
+ const entry = calls.get(decl) ?? { prod: [], test: [] };
113
+ (test ? entry.test : entry.prod).push(node);
114
+ calls.set(decl, entry);
115
+ });
116
+ }
117
+ // A function handed around as a value (a callback, an export others call dynamically) has callers the checker cannot list.
118
+ const passedAsValue = (fn) => {
119
+ const name = ts.isFunctionDeclaration(fn) ? fn.name : ts.isVariableDeclaration(fn.parent) ? fn.parent.name : undefined;
120
+ if (!name || !ts.isIdentifier(name))
121
+ return true;
122
+ const sym = checker.getSymbolAtLocation(name);
123
+ let escaped = false;
124
+ for (const sf of typed.appSources) {
125
+ if (escaped || !sf.text.includes(name.text))
126
+ continue;
127
+ h.walk(sf, node => {
128
+ if (escaped || !ts.isIdentifier(node) || node.text !== name.text || node === name)
129
+ return;
130
+ if (checker.getSymbolAtLocation(node) !== sym)
131
+ return;
132
+ const p = node.parent;
133
+ const called = ts.isCallExpression(p) && p.expression === node;
134
+ const imported = ts.isImportSpecifier(p) || ts.isExportSpecifier(p) || ts.isImportClause(p);
135
+ if (!called && !imported)
136
+ escaped = true;
137
+ });
138
+ }
139
+ return escaped;
140
+ };
141
+ for (const fn of functions) {
142
+ const entry = calls.get(fn);
143
+ if (!entry || entry.prod.length < 2 || passedAsValue(fn) || !fn.body)
144
+ continue;
145
+ fn.parameters.forEach((param, i) => {
146
+ if (param.dotDotDotToken || !ts.isIdentifier(param.name))
147
+ return;
148
+ // Literals only: two calls passing a variable of the same name do not pass the same value.
149
+ const texts = entry.prod.map(c => (c.arguments[i] ? literal(ts, c.arguments[i]) : 'undefined'));
150
+ if (texts.some(t => t === undefined) || new Set(texts).size !== 1)
151
+ return;
152
+ // Proof of dead code: the body branches on the parameter, and production only ever takes one side.
153
+ const paramSym = checker.getSymbolAtLocation(param.name);
154
+ let tested;
155
+ h.walk(fn.body, node => {
156
+ if (!tested && ts.isIdentifier(node) && node.text === param.name.text && checker.getSymbolAtLocation(node) === paramSym && inCondition(ts, node))
157
+ tested = node;
158
+ });
159
+ if (!tested)
160
+ return;
161
+ const anchor = h.touched(param) ? param : entry.prod.every(c => h.spanTouched(c)) ? entry.prod[0] : undefined;
162
+ if (!anchor)
163
+ return;
164
+ const fnName = ts.isFunctionDeclaration(fn) ? fn.name.text : fn.parent.name.text;
165
+ h.report('constant-argument', 'A parameter every production call passes the same value', anchor, `\`${fnName}\`'s \`${param.name.text}\` is ${texts[0]} in all ${entry.prod.length} production calls (e.g. ${h.at(entry.prod[0])}), yet ${h.at(tested)} branches on it: the other branch never runs outside tests.`, `Inline the value and delete the branch, or pass the real value where it is called.`);
166
+ });
167
+ }
168
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * dead-null-guard: code that guards a column against null after the query already made it non-null.
3
+ *
4
+ * A query that filters `col` (`.not(col, 'is', null)`, or a range or equality on it, which SQL never
5
+ * matches on NULL) returns rows whose `col` is never null. A guard on `row.col` in code that reads
6
+ * those rows (`?? fallback`, `?.`, `== null`, `!row.col`) can then never fire: dead code that
7
+ * reviewers keep finding round after round, and a row type that keeps telling the next reader
8
+ * otherwise. nullable-filtered-column names the type; this names each guard.
9
+ *
10
+ * Reported only when it is certain: the row type is the one the filtered query returns (`.returns<Row[]>()`
11
+ * or `read…<Row>()`), EVERY query in the program that returns that row type filters the same column,
12
+ * and no object literal builds a row of that type with the column missing or null. A guard is
13
+ * reported on a line the change touched, or anywhere in a file whose filtered query the change touched.
14
+ */
15
+ import type * as TS from 'typescript';
16
+ import type { TypedProgram } from './program.js';
17
+ type Chain = Array<{
18
+ call: TS.CallExpression;
19
+ name: string;
20
+ }>;
21
+ export interface GuardHelpers {
22
+ chainOf: (call: TS.CallExpression) => Chain;
23
+ outermost: (call: TS.CallExpression) => boolean;
24
+ rowTypeOf: (chain: Chain, outer: TS.Node) => TS.Type | undefined;
25
+ strArg: (call: TS.CallExpression, i: number) => string | undefined;
26
+ isNullLiteral: (n: TS.Node | undefined) => boolean;
27
+ isNullable: (t: TS.Type) => boolean;
28
+ touched: (node: TS.Node) => boolean;
29
+ at: (node: TS.Node) => string;
30
+ report: (id: string, title: string, node: TS.Node, details: string, hint: string) => void;
31
+ walk: (node: TS.Node, fn: (n: TS.Node) => void) => void;
32
+ }
33
+ export declare function deadNullGuards(typed: TypedProgram, changedSources: TS.SourceFile[], h: GuardHelpers): void;
34
+ export {};