@rigour-labs/core 6.6.6 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +16 -92
  2. package/dist/environment.test.js +0 -4
  3. package/dist/gates/agent-team.d.ts +0 -2
  4. package/dist/gates/agent-team.js +0 -8
  5. package/dist/gates/checkpoint.d.ts +0 -1
  6. package/dist/gates/checkpoint.js +0 -1
  7. package/dist/gates/context.d.ts +0 -4
  8. package/dist/gates/context.js +7 -17
  9. package/dist/gates/deep-analysis.d.ts +0 -1
  10. package/dist/gates/deep-runner.js +0 -1
  11. package/dist/gates/frontend-secret-exposure.test.js +2 -2
  12. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  13. package/dist/gates/hallucinated-imports/index.js +2 -2
  14. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  15. package/dist/gates/hallucinated-imports.test.js +10 -0
  16. package/dist/gates/runner.js +3 -4
  17. package/dist/gates/test-quality-matchers.d.ts +0 -4
  18. package/dist/gates/test-quality-matchers.js +0 -7
  19. package/dist/gates/test-quality.d.ts +0 -2
  20. package/dist/gates/test-quality.js +1 -10
  21. package/dist/hooks/checker.js +1 -1
  22. package/dist/hooks/index.d.ts +1 -2
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/types.d.ts +0 -14
  25. package/dist/hooks/types.js +1 -15
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +6 -1
  28. package/dist/inference/cloud-provider.d.ts +2 -0
  29. package/dist/inference/cloud-provider.js +16 -6
  30. package/dist/inference/cloud-provider.test.js +21 -0
  31. package/dist/pattern-index/embeddings.d.ts +0 -5
  32. package/dist/pattern-index/embeddings.js +11 -4
  33. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  34. package/dist/pattern-index/semantic-runtime.js +46 -0
  35. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  36. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  37. package/dist/review/backtest-judges.d.ts +38 -0
  38. package/dist/review/backtest-judges.js +63 -0
  39. package/dist/review/backtest-judges.test.d.ts +1 -0
  40. package/dist/review/backtest-judges.test.js +32 -0
  41. package/dist/review/backtest.d.ts +9 -4
  42. package/dist/review/backtest.js +19 -9
  43. package/dist/review/backtest.test.js +2 -2
  44. package/dist/review/baseline.js +2 -1
  45. package/dist/review/dead-code.test.js +7 -0
  46. package/dist/review/fix-scope.d.ts +21 -0
  47. package/dist/review/fix-scope.js +94 -0
  48. package/dist/review/fix-scope.test.d.ts +1 -0
  49. package/dist/review/fix-scope.test.js +104 -0
  50. package/dist/review/lint-overlay.js +1 -1
  51. package/dist/review/orphan-files.js +3 -2
  52. package/dist/review/partial-wiring.js +5 -3
  53. package/dist/review/query-patterns.js +3 -2
  54. package/dist/review/review.js +2 -1
  55. package/dist/review/reviewer/adapters.d.ts +23 -7
  56. package/dist/review/reviewer/adapters.js +32 -7
  57. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  58. package/dist/review/reviewer/adapters.test.js +23 -0
  59. package/dist/review/reviewer/background.d.ts +8 -1
  60. package/dist/review/reviewer/background.js +34 -9
  61. package/dist/review/reviewer/background.test.js +16 -2
  62. package/dist/review/reviewer/consensus.d.ts +36 -0
  63. package/dist/review/reviewer/consensus.js +144 -0
  64. package/dist/review/reviewer/context.d.ts +75 -0
  65. package/dist/review/reviewer/context.js +140 -0
  66. package/dist/review/reviewer/context.test.d.ts +1 -0
  67. package/dist/review/reviewer/context.test.js +18 -0
  68. package/dist/review/reviewer/exec.js +6 -2
  69. package/dist/review/reviewer/exec.test.d.ts +1 -0
  70. package/dist/review/reviewer/exec.test.js +23 -0
  71. package/dist/review/reviewer/inputs.d.ts +2 -0
  72. package/dist/review/reviewer/inputs.js +5 -1
  73. package/dist/review/reviewer/panel.d.ts +67 -0
  74. package/dist/review/reviewer/panel.js +101 -0
  75. package/dist/review/reviewer/panel.test.d.ts +1 -0
  76. package/dist/review/reviewer/panel.test.js +110 -0
  77. package/dist/review/reviewer/prompt.d.ts +11 -0
  78. package/dist/review/reviewer/prompt.js +26 -4
  79. package/dist/review/reviewer/settings.d.ts +58 -0
  80. package/dist/review/reviewer/settings.js +152 -0
  81. package/dist/review/reviewer/settings.test.d.ts +1 -0
  82. package/dist/review/reviewer/settings.test.js +57 -0
  83. package/dist/review/reviewer/store.d.ts +25 -2
  84. package/dist/review/reviewer/store.js +43 -1
  85. package/dist/review/reviewer/usage.d.ts +2 -0
  86. package/dist/review/reviewer/usage.js +28 -0
  87. package/dist/review/reviewer/usage.test.d.ts +1 -0
  88. package/dist/review/reviewer/usage.test.js +14 -0
  89. package/dist/review/reviewer/verdict.d.ts +15 -1
  90. package/dist/review/reviewer/verdict.js +17 -7
  91. package/dist/review/reviewer.d.ts +45 -2
  92. package/dist/review/reviewer.js +186 -25
  93. package/dist/review/reviewer.test.js +127 -12
  94. package/dist/review/test-files.d.ts +5 -1
  95. package/dist/review/test-files.js +6 -2
  96. package/dist/review/toolchain.js +10 -3
  97. package/dist/review/toolchain.test.js +6 -3
  98. package/dist/review/typed/program.d.ts +0 -1
  99. package/dist/review/typed/program.js +3 -3
  100. package/dist/review/typed/redundancy.js +43 -4
  101. package/dist/review/typed/redundancy.test.js +58 -0
  102. package/dist/review/typed/schema-nullability.d.ts +6 -0
  103. package/dist/review/typed/schema-nullability.js +218 -0
  104. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  105. package/dist/review/typed/schema-nullability.test.js +63 -0
  106. package/dist/review/unused-exports.js +5 -6
  107. package/dist/semantic/learn/types.d.ts +2 -2
  108. package/dist/services/adaptive-thresholds.d.ts +2 -52
  109. package/dist/services/adaptive-thresholds.js +2 -147
  110. package/dist/services/adaptive-thresholds.test.js +1 -81
  111. package/dist/settings.d.ts +14 -6
  112. package/dist/settings.js +0 -12
  113. package/dist/storage/team-diagnostics.js +1 -1
  114. package/dist/storage/team-schema.d.ts +1 -1
  115. package/dist/storage/team-schema.js +2 -0
  116. package/dist/storage/team-store.js +26 -3
  117. package/dist/storage/team-sync-scope.test.js +35 -2
  118. package/dist/telemetry/telemetry.d.ts +5 -0
  119. package/dist/telemetry/telemetry.js +11 -1
  120. package/dist/templates/paradigms.js +0 -8
  121. package/dist/templates/presets.d.ts +0 -1
  122. package/dist/templates/presets.js +0 -23
  123. package/dist/templates/universal-config.js +8 -45
  124. package/dist/types/index.d.ts +108 -566
  125. package/dist/types/index.js +41 -66
  126. package/dist/types/index.test.d.ts +1 -0
  127. package/dist/types/index.test.js +33 -0
  128. package/dist/utils/command-line.d.ts +8 -0
  129. package/dist/utils/command-line.js +8 -0
  130. package/dist/utils/command-line.test.d.ts +1 -0
  131. package/dist/utils/command-line.test.js +8 -0
  132. package/dist/utils/profile.js +1 -1
  133. package/package.json +6 -7
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A fix round's scope: the files the branch's own commits changed since the review they answer,
3
+ * less the files that review cited. A fix round changes what the review asked for and nothing
4
+ * else, so every extra file is either a point the reviewer never made or work for a follow-up.
5
+ *
6
+ * Cited means: the path of an inline comment of that review round (every review a person
7
+ * submitted on the same commit), or a tracked file the review text names (`src/a.ts:12`,
8
+ * `a.ts`, a blob link), when the name points at exactly one file. A test of a cited file is in
9
+ * scope when it sits beside it (or in its `__tests__`). Merges are left out (a merge from main brings main's files, not the round's), and so is
10
+ * a file the round changed and then put back.
11
+ */
12
+ import path from 'path';
13
+ import { defaultExec, githubEnv, parseJsonArrays } from './reviewer/exec.js';
14
+ import { findPullRequest, ghFor, isHuman } from './reviewer/inputs.js';
15
+ import { isTestFile } from './test-files.js';
16
+ const GIT_TIMEOUT_MS = 60_000;
17
+ export async function fixScope(cwd, options, exec = defaultExec) {
18
+ const git = async (args) => exec('git', args, { cwd, timeoutMs: GIT_TIMEOUT_MS });
19
+ const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
20
+ const head = (await git(['rev-parse', 'HEAD'])).stdout.trim();
21
+ if (!head)
22
+ return { error: 'not a git repository' };
23
+ const gh = ghFor(cwd, exec, await githubEnv(cwd, options.githubAccount, exec));
24
+ const found = await findPullRequest(gh, branch, head, options.pr);
25
+ if (!found.pr)
26
+ return { error: found.error ?? `no pull request for ${branch}: name one with --scope <number>` };
27
+ const pr = found.pr;
28
+ const reviews = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/reviews`, '--paginate']);
29
+ const comments = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/comments`, '--paginate']);
30
+ if (reviews.exitCode !== 0 || comments.exitCode !== 0)
31
+ return { error: `could not read the reviews of pull request ${pr.number}: ${(reviews.stderr || comments.stderr).trim().slice(0, 200)}` };
32
+ const human = parseJsonArrays(reviews.stdout).filter((r) => isHuman(r, pr.author) && r.commit_id);
33
+ const target = options.review !== undefined ? human.find((r) => r.id === options.review) : human.at(-1);
34
+ if (!target)
35
+ return { error: options.review !== undefined ? `no review ${options.review} by a person on pull request ${pr.number}` : `pull request ${pr.number} has no review by a person yet` };
36
+ if ((await git(['merge-base', '--is-ancestor', target.commit_id, 'HEAD'])).exitCode !== 0) {
37
+ return { error: `the reviewed commit ${String(target.commit_id).slice(0, 9)} is not behind HEAD: fetch it, or the branch was rewritten since the review` };
38
+ }
39
+ const round = human.filter((r) => r.commit_id === target.commit_id);
40
+ const roundIds = new Set(round.map((r) => r.id));
41
+ const inline = parseJsonArrays(comments.stdout).filter((c) => isHuman(c, pr.author) && roundIds.has(c.pull_request_review_id));
42
+ const tracked = trackedIndex((await git(['ls-files'])).stdout.split('\n').filter(Boolean));
43
+ const cited = new Set(inline.map((c) => String(c.path)));
44
+ for (const text of [...round.map((r) => r.body), ...inline.map((c) => c.body)])
45
+ for (const file of mentioned(String(text ?? ''), tracked))
46
+ cited.add(file);
47
+ const changed = await changedByTheRound(git, target.commit_id);
48
+ const covered = (file) => cited.has(file) || (isTestFile(file) && [...cited].some(c => subject(c) === subject(file)));
49
+ return {
50
+ scope: {
51
+ pr: pr.number,
52
+ review: { id: target.id, login: target.user.login, submittedAt: String(target.submitted_at ?? ''), commit: target.commit_id },
53
+ cited: [...cited].sort(),
54
+ changed,
55
+ extra: changed.filter(file => !covered(file)),
56
+ },
57
+ };
58
+ }
59
+ /** Files the branch's own commits (not its merges) changed since `commit`, and that still differ from it. */
60
+ async function changedByTheRound(git, commit) {
61
+ const own = new Set((await git(['log', '--first-parent', '--no-merges', '--name-only', '--format=', `${commit}..HEAD`])).stdout.split('\n').filter(Boolean));
62
+ const differ = (await git(['diff', '--name-only', commit, 'HEAD'])).stdout.split('\n').filter(Boolean);
63
+ return differ.filter(file => own.has(file)).sort();
64
+ }
65
+ /** Tracked files by base name, built once for every review text. */
66
+ function trackedIndex(files) {
67
+ const index = new Map();
68
+ for (const file of files) {
69
+ const base = path.posix.basename(file);
70
+ const same = index.get(base);
71
+ if (same)
72
+ same.push(file);
73
+ else
74
+ index.set(base, [file]);
75
+ }
76
+ return index;
77
+ }
78
+ /** Tracked files a review's text names, by path, path suffix or blob link, when the name is unambiguous. */
79
+ function mentioned(text, tracked) {
80
+ const found = new Set();
81
+ for (const raw of text.match(/[\w@$./-]+\.[A-Za-z][\w]*/g) ?? []) {
82
+ const name = raw.replace(/^.*\/blob\/[^/]+\//, '').replace(/^\.\//, '');
83
+ const matches = (tracked.get(path.posix.basename(name)) ?? []).filter(file => file === name || file.endsWith(`/${name}`));
84
+ const exact = matches.find(file => file === name);
85
+ if (exact || matches.length === 1)
86
+ found.add(exact ?? matches[0]);
87
+ }
88
+ return [...found];
89
+ }
90
+ /** What a file is about: `src/a.ts`, `src/a.test.ts` and `src/__tests__/a.spec.ts` are all `src/a`. */
91
+ function subject(file) {
92
+ const dir = path.posix.dirname(file).replace(/\/(__tests__|tests?)$/, '');
93
+ return path.posix.join(dir, path.posix.basename(file).replace(/\.(test|spec|test-d)(?=\.)/, '').replace(/\.[^.]+$/, ''));
94
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
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 { fixScope as scopeWith } from './fix-scope.js';
7
+ let repo;
8
+ let reviewed;
9
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
10
+ const write = (rel, body) => {
11
+ fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
12
+ fs.writeFileSync(path.join(repo, rel), body);
13
+ };
14
+ const commit = (message) => {
15
+ git('add', '-A');
16
+ git('commit', '-qm', message);
17
+ };
18
+ const PR = { number: 42, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: '' };
19
+ const user = (login, type = 'User') => ({ login, type });
20
+ let reviews;
21
+ let comments;
22
+ /** Real git; gh answers from `reviews` and `comments`. */
23
+ const exec = async (command, args, options) => {
24
+ if (command === 'git') {
25
+ try {
26
+ return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }), stderr: '' };
27
+ }
28
+ catch (error) {
29
+ return { exitCode: 1, stdout: '', stderr: String(error.message) };
30
+ }
31
+ }
32
+ if (args[0] === 'pr')
33
+ return { exitCode: 0, stdout: JSON.stringify(PR), stderr: '' };
34
+ if (args[1]?.endsWith('/reviews'))
35
+ return { exitCode: 0, stdout: JSON.stringify(reviews), stderr: '' };
36
+ if (args[1]?.endsWith('/comments'))
37
+ return { exitCode: 0, stdout: JSON.stringify(comments), stderr: '' };
38
+ return { exitCode: 1, stdout: '', stderr: 'unexpected gh call' };
39
+ };
40
+ beforeEach(() => {
41
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'fix-scope-'));
42
+ git('init', '-q', '-b', 'main');
43
+ git('config', 'user.email', 't@example.com');
44
+ git('config', 'user.name', 't');
45
+ git('config', 'commit.gpgsign', 'false');
46
+ for (const file of ['src/job.ts', 'src/job.test.ts', 'src/util.ts', 'src/deep/thing.ts', 'src/other.ts', 'src/temp.ts', 'src/mainside.ts', 'lib/util.ts'])
47
+ write(file, 'export const x = 1;\n');
48
+ commit('base');
49
+ git('checkout', '-qb', 'feature');
50
+ write('src/job.ts', 'export const x = 2;\n');
51
+ commit('the change under review');
52
+ reviewed = git('rev-parse', 'HEAD');
53
+ reviews = [
54
+ { id: 1, user: user('ci-bot', 'Bot'), body: 'see src/other.ts', submitted_at: '2026-10-01', commit_id: reviewed },
55
+ { id: 2, user: user('author'), body: 'I will also tidy src/other.ts', submitted_at: '2026-10-02', commit_id: reviewed },
56
+ { id: 3, user: user('senior'), body: 'Bound the window in `src/util.ts:4`. The scan in https://github.com/o/r/blob/abc123/src/deep/thing.ts#L3 is quadratic.', submitted_at: '2026-10-03', commit_id: reviewed },
57
+ { id: 4, user: user('second'), body: '', submitted_at: '2026-10-03', commit_id: reviewed },
58
+ ];
59
+ comments = [
60
+ { id: 9, pull_request_review_id: 4, user: user('second'), path: 'src/job.ts', body: 'Lock before the first read.' },
61
+ { id: 10, pull_request_review_id: 2, user: user('author'), path: 'src/temp.ts', body: 'my own note' },
62
+ ];
63
+ });
64
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
65
+ const fixScope = (cwd, named, run) => scopeWith(cwd, { pr: undefined, review: named.review, githubAccount: undefined }, run);
66
+ describe('the scope of a fix round', () => {
67
+ it('lists the files the round changed that no point of the review cited', async () => {
68
+ for (const file of ['src/job.ts', 'src/job.test.ts', 'src/util.ts', 'src/deep/thing.ts', 'src/other.ts'])
69
+ write(file, 'export const x = 3;\n');
70
+ commit('fix round');
71
+ write('src/temp.ts', 'export const x = 9;\n');
72
+ commit('try something');
73
+ write('src/temp.ts', 'export const x = 1;\n');
74
+ commit('put it back');
75
+ git('checkout', '-q', 'main');
76
+ write('src/mainside.ts', 'export const x = 4;\n');
77
+ commit('main moves on');
78
+ git('checkout', '-q', 'feature');
79
+ git('merge', '-q', '--no-edit', 'main');
80
+ const { scope, error } = await fixScope(repo, {}, exec);
81
+ expect(error).toBeUndefined();
82
+ expect(scope).toMatchObject({ pr: 42, review: { id: 4, login: 'second', commit: reviewed } });
83
+ expect(scope.cited).toEqual(['src/deep/thing.ts', 'src/job.ts', 'src/util.ts']);
84
+ expect(scope.changed).toEqual(['src/deep/thing.ts', 'src/job.test.ts', 'src/job.ts', 'src/other.ts', 'src/util.ts']);
85
+ expect(scope.extra).toEqual(['src/other.ts']);
86
+ });
87
+ it('takes a named review as the round, and an ambiguous file name cites nothing', async () => {
88
+ write('src/job.ts', 'export const x = 3;\n');
89
+ commit('first fix round');
90
+ reviews.push({ id: 5, user: user('senior'), body: 'and util.ts too', submitted_at: '2026-10-04', commit_id: git('rev-parse', 'HEAD') });
91
+ write('src/util.ts', 'export const x = 3;\n');
92
+ commit('second fix round');
93
+ const named = await fixScope(repo, { review: 5 }, exec);
94
+ expect(named.scope.cited).toEqual([]);
95
+ expect(named.scope.extra).toEqual(['src/util.ts']);
96
+ });
97
+ it('says why it cannot answer', async () => {
98
+ expect((await fixScope(repo, { review: 99 }, exec)).error).toBe('no review 99 by a person on pull request 42');
99
+ reviews = reviews.filter((r) => r.user.type === 'Bot');
100
+ expect((await fixScope(repo, {}, exec)).error).toBe('pull request 42 has no review by a person yet');
101
+ reviews = [{ id: 3, user: user('senior'), body: 'x', submitted_at: '2026-10-03', commit_id: 'f'.repeat(40) }];
102
+ expect((await fixScope(repo, {}, exec)).error).toMatch(/^the reviewed commit fffffffff is not behind HEAD/);
103
+ });
104
+ });
@@ -59,7 +59,7 @@ export default [
59
59
  },
60
60
  { files: ['**/*.svelte'], rules: typed },
61
61
  // A test fake deliberately covers cases the types call impossible.
62
- { files: ['**/*.test.ts', '**/*.spec.ts', '**/*.svelte.test.ts', 'tests/**', 'test/**', '**/__tests__/**'], rules: { '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-floating-promises': 'off' } },
62
+ { files: ['**/*.test.ts', '**/*.spec.ts', '**/*.test-d.ts', '**/*.svelte.test.ts', 'tests/**', 'test/**', 'test-d/**', '**/__tests__/**', '**/__mocks__/**'], rules: { '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-floating-promises': 'off' } },
63
63
  ];
64
64
  `);
65
65
  return file;
@@ -16,9 +16,10 @@ import micromatch from 'micromatch';
16
16
  import { addedFiles } from './migration-order.js';
17
17
  import { sourceStem } from './package-layout.js';
18
18
  import { ownOutputs } from './unused-exports.js';
19
+ import { isTestFile } from './test-files.js';
19
20
  const GIT_TIMEOUT_MS = 10_000;
20
21
  const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
21
- const FOUND_BY_RUNNER = /(^|\/)\+[^/]+$|(^|\/)hooks(\.server|\.client)?\.(ts|js)$|^netlify\/functions\/|^src\/params\/|\.(test|spec|e2e)\.[a-z]+$|\.d\.ts$|^(tests?|e2e|__tests__|migrations|supabase)\/|\.config\.[a-z]+$|^\./;
22
+ const FOUND_BY_RUNNER = /(^|\/)\+[^/]+$|(^|\/)hooks(\.server|\.client)?\.(ts|js)$|^netlify\/functions\/|^src\/params\/|\.d\.ts$|^(migrations|supabase)\/|\.config\.[a-z]+$|^\./;
22
23
  /** Files that can run or import another: code, package.json, CI and config. */
23
24
  const REFERRER = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|json|ya?ml|toml|sh)$/;
24
25
  /** Lockfiles and generated bundles name no source file and can be megabytes. */
@@ -32,7 +33,7 @@ export function orphanFileFailures(cwd, diff, config) {
32
33
  const settings = config.gates.orphan_files;
33
34
  if (!settings?.enabled)
34
35
  return [];
35
- const added = addedFiles(diff).filter(file => CODE.test(file) && !FOUND_BY_RUNNER.test(file) && !BUILD_OUTPUT.test(file) && !micromatch.isMatch(file, settings.allow));
36
+ const added = addedFiles(diff).filter(file => CODE.test(file) && !FOUND_BY_RUNNER.test(file) && !isTestFile(file) && !BUILD_OUTPUT.test(file) && !micromatch.isMatch(file, settings.allow));
36
37
  if (added.length === 0)
37
38
  return [];
38
39
  const excluded = ownOutputs(config);
@@ -8,8 +8,10 @@
8
8
  import { spawnSync } from 'child_process';
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
+ import { isTestFile } from './test-files.js';
11
12
  const MOUNTING = /\.(svelte|tsx|jsx)$/;
12
- const SKIPPED = /\.(test|spec|stories)\.|(^|\/)(__tests__|tests?)\//;
13
+ /** Tests and stories mount components to exercise them, not to wire behaviour. */
14
+ const skipped = (file) => isTestFile(file) || /\.stories\./.test(file);
13
15
  const GIT_TIMEOUT_MS = 10_000;
14
16
  export function partialWiringFailures(cwd, changedLines, config) {
15
17
  if (!config.gates.change_sweep?.enabled)
@@ -17,7 +19,7 @@ export function partialWiringFailures(cwd, changedLines, config) {
17
19
  const failures = [];
18
20
  const seen = new Set();
19
21
  for (const [file, lines] of Object.entries(changedLines)) {
20
- if (!MOUNTING.test(file) || SKIPPED.test(file) || lines.size === 0)
22
+ if (!MOUNTING.test(file) || skipped(file) || lines.size === 0)
21
23
  continue;
22
24
  for (const mount of mountsIn(cwd, file)) {
23
25
  for (const { prop, line } of addedProps(cwd, mount, lines)) {
@@ -75,7 +77,7 @@ function mountsOf(cwd, component) {
75
77
  if (cached)
76
78
  return cached;
77
79
  const grep = spawnSync('git', ['grep', '--untracked', '-l', '-F', '-e', `<${component}`], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
78
- const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && MOUNTING.test(f) && !SKIPPED.test(f)) : [];
80
+ const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && MOUNTING.test(f) && !skipped(f)) : [];
79
81
  const mounts = files.flatMap(file => mountsIn(cwd, file).filter(m => m.component === component));
80
82
  mountCache.set(key, mounts);
81
83
  return mounts;
@@ -14,11 +14,12 @@
14
14
  *
15
15
  * Read from the syntax tree, so a comment or string never matches.
16
16
  */
17
+ import { isTestFile } from './test-files.js';
17
18
  import fs from 'fs';
18
19
  import path from 'path';
19
20
  import ts from 'typescript';
20
21
  const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
21
- const SKIPPED = /\.(test|spec)\.|\.d\.ts$|(^|\/)(__tests__|tests?|e2e)\//;
22
+ const DECLARATIONS = /\.d\.ts$/;
22
23
  const TIME_COLUMN = /(_at|_on|At|On|date|Date|time|Time|timestamp|Timestamp)$|^(at|since|until|created|updated)$/;
23
24
  const LOWER = new Set(['gt', 'gte']);
24
25
  const UPPER = new Set(['lt', 'lte']);
@@ -27,7 +28,7 @@ export function queryPatternFailures(cwd, changedLines, config) {
27
28
  return [];
28
29
  const failures = [];
29
30
  for (const [file, lines] of Object.entries(changedLines)) {
30
- if (!CODE.test(file) || SKIPPED.test(file) || lines.size === 0)
31
+ if (!CODE.test(file) || DECLARATIONS.test(file) || isTestFile(file) || lines.size === 0)
31
32
  continue;
32
33
  let text;
33
34
  try {
@@ -38,7 +38,8 @@ export async function reviewChange(input) {
38
38
  return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff), hints: [] };
39
39
  }
40
40
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
41
- const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
41
+ // The team's `commands:` run at push (toolchain.ts), where a failure blocks; here they would only cost time.
42
+ const report = await new GateRunner({ ...input.config, commands: {} }).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
42
43
  const preexisting = await dropPreexisting(input, report, targets);
43
44
  if (input.diffTests && deep)
44
45
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
@@ -1,4 +1,4 @@
1
- import type { Exec } from './exec.js';
1
+ import { type Exec } from './exec.js';
2
2
  export type ReviewerName = 'claude' | 'cursor' | 'codex';
3
3
  export type Vendor = 'anthropic' | 'cursor' | 'openai';
4
4
  export type ReviewMode = 'single' | 'cross' | 'full';
@@ -7,11 +7,19 @@ export interface Adapter {
7
7
  binary: string;
8
8
  /** The command line for one review: the prompt is passed as text, never through a shell. */
9
9
  args(prompt: string, model: string | undefined): string[];
10
- /** The reviewer's final message and, when the CLI reports it, what the run cost. */
10
+ /** The reviewer's final message and, when the CLI reports them, what the run cost and the tokens it used. */
11
11
  answer(stdout: string): {
12
12
  text: string;
13
- costUsd?: number;
14
- };
13
+ } & Spend;
14
+ }
15
+ /** What a run used: dollars when the CLI reports them (Claude Code), tokens otherwise (Codex reports only tokens). */
16
+ export interface Spend {
17
+ costUsd?: number;
18
+ tokens?: Tokens;
19
+ }
20
+ export interface Tokens {
21
+ input: number;
22
+ output: number;
15
23
  }
16
24
  export declare const ADAPTERS: Record<ReviewerName, Adapter>;
17
25
  export declare function isReviewerName(name: string): name is ReviewerName;
@@ -29,7 +37,15 @@ export declare function resolveAdapter(adapter: Adapter, cwd: string, exec: Exec
29
37
  export declare function vendorsOf(trailers: string): Set<Vendor>;
30
38
  /**
31
39
  * The reviewers a run uses. single: the first available of the list. cross: the first available
32
- * whose vendor is not on the trailers, else the first available. full: that one plus the first
33
- * available of another vendor. Empty when none is installed.
40
+ * whose vendor is not on the trailers, else the first available. full: that one plus the next
41
+ * available of each other vendor, up to `judges`. Empty when none is installed.
34
42
  */
35
- export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>): ReviewerName[];
43
+ export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>, judges?: number): ReviewerName[];
44
+ /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
45
+ export declare function reviewerAvailability(cwd: string, exec?: Exec): Promise<Array<{
46
+ name: ReviewerName;
47
+ vendor: Vendor;
48
+ binary: string;
49
+ installed: boolean;
50
+ version?: string;
51
+ }>>;
@@ -9,6 +9,8 @@
9
9
  import fs from 'fs';
10
10
  import os from 'os';
11
11
  import path from 'path';
12
+ import { defaultExec } from './exec.js';
13
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
12
14
  const READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob', 'Bash(git diff:*)', 'Bash(git show:*)', 'Bash(git log:*)', 'Bash(git grep:*)'];
13
15
  export const ADAPTERS = {
14
16
  claude: {
@@ -29,7 +31,12 @@ export const ADAPTERS = {
29
31
  answer: stdout => {
30
32
  try {
31
33
  const parsed = JSON.parse(stdout);
32
- return { text: String(parsed.result ?? ''), ...(typeof parsed.total_cost_usd === 'number' ? { costUsd: parsed.total_cost_usd } : {}) };
34
+ const usage = parsed.usage;
35
+ return {
36
+ text: String(parsed.result ?? ''),
37
+ ...(typeof parsed.total_cost_usd === 'number' ? { costUsd: parsed.total_cost_usd } : {}),
38
+ ...(usage ? { tokens: { input: n(usage.input_tokens) + n(usage.cache_read_input_tokens) + n(usage.cache_creation_input_tokens), output: n(usage.output_tokens) } } : {}),
39
+ };
33
40
  }
34
41
  catch {
35
42
  return { text: stdout };
@@ -56,20 +63,25 @@ export const ADAPTERS = {
56
63
  binary: 'codex',
57
64
  args: (prompt, model) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', 'model_reasoning_effort=high', prompt],
58
65
  // `codex exec --json` streams events; the last text-bearing one carries the answer.
66
+ // Warnings arrive as `error` items with a `message`, not `text`, so they are never taken for the answer.
67
+ // `turn.completed` carries the tokens (Codex reports no dollars).
59
68
  answer: stdout => {
60
69
  let text = '';
70
+ let tokens;
61
71
  for (const line of stdout.split('\n').filter(Boolean)) {
62
72
  try {
63
73
  const event = JSON.parse(line);
64
74
  const candidate = event?.item?.text ?? event?.msg?.message ?? event?.text;
65
75
  if (typeof candidate === 'string')
66
76
  text = candidate;
77
+ if (event?.type === 'turn.completed' && event.usage)
78
+ tokens = { input: n(event.usage.input_tokens), output: n(event.usage.output_tokens) + n(event.usage.reasoning_output_tokens) };
67
79
  }
68
80
  catch {
69
81
  // not an event line
70
82
  }
71
83
  }
72
- return { text: text || stdout };
84
+ return { text: text || stdout, ...(tokens ? { tokens } : {}) };
73
85
  },
74
86
  },
75
87
  };
@@ -139,10 +151,10 @@ export function vendorsOf(trailers) {
139
151
  }
140
152
  /**
141
153
  * The reviewers a run uses. single: the first available of the list. cross: the first available
142
- * whose vendor is not on the trailers, else the first available. full: that one plus the first
143
- * available of another vendor. Empty when none is installed.
154
+ * whose vendor is not on the trailers, else the first available. full: that one plus the next
155
+ * available of each other vendor, up to `judges`. Empty when none is installed.
144
156
  */
145
- export function selectReviewers(candidates, mode, authors, available) {
157
+ export function selectReviewers(candidates, mode, authors, available, judges = 2) {
146
158
  const installed = candidates.filter(name => available.has(name));
147
159
  if (installed.length === 0)
148
160
  return [];
@@ -151,6 +163,19 @@ export function selectReviewers(candidates, mode, authors, available) {
151
163
  first = installed.find(name => !authors.has(ADAPTERS[name].vendor)) ?? first;
152
164
  if (mode !== 'full')
153
165
  return [first];
154
- const second = installed.find(name => name !== first && ADAPTERS[name].vendor !== ADAPTERS[first].vendor);
155
- return second ? [first, second] : [first];
166
+ const chosen = [first];
167
+ for (const name of installed) {
168
+ if (chosen.length >= judges)
169
+ break;
170
+ if (!chosen.some(c => ADAPTERS[c].vendor === ADAPTERS[name].vendor))
171
+ chosen.push(name);
172
+ }
173
+ return chosen;
174
+ }
175
+ /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
176
+ export async function reviewerAvailability(cwd, exec = defaultExec) {
177
+ return Promise.all(Object.keys(ADAPTERS).map(async (name) => {
178
+ const found = await resolveAdapter(ADAPTERS[name], cwd, exec);
179
+ return { name, vendor: ADAPTERS[name].vendor, binary: ADAPTERS[name].binary, installed: !!found, ...(found ? { version: found.version } : {}) };
180
+ }));
156
181
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ADAPTERS } from './adapters.js';
3
+ /** A real `codex exec --json` run (codex-cli 0.160.1), the warning's path shortened: the shape the adapter must read. */
4
+ const CODEX = [
5
+ '{"type":"thread.started","thread_id":"01a11477-f9fd-7472-8d31-5863de4cbbdb"}',
6
+ '{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Codex is ignoring 1 unrecognized configuration setting. Check for typos or deprecated settings.\\n user (~/.codex/config.toml)"}}',
7
+ '{"type":"turn.started"}',
8
+ '{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"answers\\":[{\\"id\\":\\"a1\\",\\"call\\":\\"unsure\\",\\"evidence\\":\\"none\\"}]}"}}',
9
+ '{"type":"turn.completed","usage":{"input_tokens":26565,"cached_input_tokens":13184,"cache_write_input_tokens":0,"output_tokens":24,"reasoning_output_tokens":0}}',
10
+ ].join('\n');
11
+ describe('reading an agent CLI\'s answer', () => {
12
+ it('takes Codex\'s last agent message, never a warning, and its tokens', () => {
13
+ expect(ADAPTERS.codex.answer(CODEX)).toEqual({ text: '{"answers":[{"id":"a1","call":"unsure","evidence":"none"}]}', tokens: { input: 26565, output: 24 } });
14
+ });
15
+ it('takes Claude Code\'s result, its cost and its tokens', () => {
16
+ const out = JSON.stringify({ result: '{"prior_points":[]}', total_cost_usd: 0.42, usage: { input_tokens: 10, cache_read_input_tokens: 900, cache_creation_input_tokens: 90, output_tokens: 300 } });
17
+ expect(ADAPTERS.claude.answer(out)).toEqual({ text: '{"prior_points":[]}', costUsd: 0.42, tokens: { input: 1000, output: 300 } });
18
+ });
19
+ it('falls back to the raw output when it is not what the CLI usually prints', () => {
20
+ expect(ADAPTERS.claude.answer('not json')).toEqual({ text: 'not json' });
21
+ expect(ADAPTERS.codex.answer('plain text')).toEqual({ text: 'plain text' });
22
+ });
23
+ });
@@ -1,6 +1,7 @@
1
1
  import type { Config } from '../../types/index.js';
2
- import { type ReviewerResult } from '../reviewer.js';
2
+ import { type ModeRecord, type ReviewerResult } from '../reviewer.js';
3
3
  import { type Exec } from './exec.js';
4
+ import { type DaySpend, type ReviewAttempt } from './store.js';
4
5
  import { type OpenItem } from './verdict.js';
5
6
  export interface BackgroundJob {
6
7
  head: string;
@@ -21,12 +22,18 @@ export interface ReviewStatus {
21
22
  pid: number;
22
23
  head: string;
23
24
  };
25
+ /** What the reviewer spent today in this repository: the count the daily caps are checked against. */
26
+ today: DaySpend;
27
+ /** The last review that ended without a verdict, when it is newer than the last verdict. */
28
+ attempt?: ReviewAttempt;
24
29
  /** The last verdict recorded for the branch. */
25
30
  last?: {
26
31
  head: string;
27
32
  mode: 'full' | 'delta';
28
33
  at: string;
29
34
  open: OpenItem[];
35
+ ran?: ModeRecord;
36
+ disputed: OpenItem[];
30
37
  };
31
38
  log?: string;
32
39
  }
@@ -11,8 +11,9 @@ import { spawn, spawnSync } from 'child_process';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { reviewChange } from '../review.js';
14
- import { runReviewer } from '../reviewer.js';
14
+ import { pushReviewSkip, runReviewer } from '../reviewer.js';
15
15
  import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
16
+ import { dismissedAs, readReviewDismissals, reviewerInputs } from './context.js';
16
17
  import { VerdictStore } from './store.js';
17
18
  import { itemLine } from './verdict.js';
18
19
  /**
@@ -38,6 +39,13 @@ export async function backgroundReview(cwd, job, config, exec = defaultExec, log
38
39
  const store = await VerdictStore.open(cwd, exec);
39
40
  if (!store)
40
41
  throw new Error(`${cwd} is not a repository`);
42
+ const skip = await pushReviewSkip(cwd, job.branch, job.head, config, exec);
43
+ if (skip) {
44
+ store.recordAttempt(job.branch, { head: job.head, outcome: 'skipped', reason: skip, at: new Date().toISOString() });
45
+ log(`review of ${job.head.slice(0, 9)}: skipped (${skip})`);
46
+ clearPid(store, job.branch);
47
+ return { outcome: 'skipped', items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reason: skip, reviewers: [], cached: false };
48
+ }
41
49
  const worktree = store.worktreeDir(job.head);
42
50
  const added = await exec('git', ['worktree', 'add', '--detach', worktree, job.head], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
43
51
  if (added.exitCode !== 0 && !fs.existsSync(path.join(worktree, '.git')))
@@ -45,8 +53,9 @@ export async function backgroundReview(cwd, job, config, exec = defaultExec, log
45
53
  try {
46
54
  shareDependencies(cwd, worktree);
47
55
  // The typed checks' hints (a nested scan, a value that only leaves through serialisation) are the reviewer's candidates to confirm.
48
- const hints = (await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: job.base }, typed: true })).hints.join('\n');
49
- const result = await runReviewer(worktree, job.base, config, exec, log, { trigger: 'push', branch: job.branch, hints });
56
+ const review = await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: job.base }, typed: true });
57
+ // The worktree has the commit, not the team's untracked state: dismissals and reviewed functions come from the checkout.
58
+ const result = await runReviewer(worktree, job.base, config, exec, log, { trigger: 'push', branch: job.branch, stateRoot: cwd, ...reviewerInputs(review) });
50
59
  log(`review of ${job.head.slice(0, 9)}: ${result.outcome}${result.reason ? ` (${result.reason})` : ''}${result.items.length ? `, ${result.items.length} open item(s)` : ''}`);
51
60
  for (const item of result.items)
52
61
  log(` ${itemLine(item)}`);
@@ -55,9 +64,7 @@ export async function backgroundReview(cwd, job, config, exec = defaultExec, log
55
64
  }
56
65
  finally {
57
66
  await exec('git', ['worktree', 'remove', '--force', worktree], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
58
- const pidFile = store.branchFile(job.branch, 'pid');
59
- if (fs.existsSync(pidFile) && fs.readFileSync(pidFile, 'utf8').startsWith(`${process.pid} `))
60
- fs.unlinkSync(pidFile);
67
+ clearPid(store, job.branch);
61
68
  }
62
69
  }
63
70
  /** What the background reviewer has done for a branch: running, the last verdict and its open items. */
@@ -65,13 +72,25 @@ export async function reviewStatus(cwd, branch, exec = defaultExec) {
65
72
  const store = await VerdictStore.open(cwd, exec);
66
73
  if (!store)
67
74
  return undefined;
68
- const status = { branch };
75
+ const status = { branch, today: store.spend() };
69
76
  const running = runningJob(store, branch);
70
77
  if (running)
71
78
  status.running = running;
72
79
  const state = store.branchState(branch);
73
- if (state)
74
- status.last = { head: state.head, mode: state.mode, at: state.at, open: store.readJson(store.openPath(state.verdict)) ?? [] };
80
+ const attempt = store.attempt(branch);
81
+ if (attempt && (!state || attempt.at > state.at))
82
+ status.attempt = attempt;
83
+ if (state) {
84
+ const verdict = store.readJson(state.verdict);
85
+ const dismissals = readReviewDismissals(cwd);
86
+ status.last = {
87
+ head: state.head, mode: state.mode, at: state.at,
88
+ // A finding dismissed since this verdict is not work any more: it leaves the list now, not at the next review.
89
+ open: (store.readJson(store.openPath(state.verdict)) ?? []).filter(item => !dismissedAs(item, dismissals)),
90
+ ...(verdict?.inputs?.mode ? { ran: verdict.inputs.mode } : {}),
91
+ disputed: (verdict?.panel?.items ?? []).filter(d => d.status === 'disputed').map(d => ({ ...d.item, reviewer: d.judges.join('+') })),
92
+ };
93
+ }
75
94
  const log = store.branchFile(branch, 'log');
76
95
  if (fs.existsSync(log))
77
96
  status.log = log;
@@ -126,3 +145,9 @@ function notify(message) {
126
145
  else if (process.platform === 'linux')
127
146
  spawnSync('notify-send', ['Rigour', safe], { stdio: 'ignore', timeout: 5000 });
128
147
  }
148
+ /** This process's pid record for the branch, removed when it ends (a newer job's record is left alone). */
149
+ function clearPid(store, branch) {
150
+ const pidFile = store.branchFile(branch, 'pid');
151
+ if (fs.existsSync(pidFile) && fs.readFileSync(pidFile, 'utf8').startsWith(`${process.pid} `))
152
+ fs.unlinkSync(pidFile);
153
+ }
@@ -11,8 +11,8 @@ let bin;
11
11
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
12
12
  const config = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true } } });
13
13
  const originalPath = process.env.PATH;
14
- const PR = { number: 7, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'desc' };
15
- const VERDICT = JSON.stringify({ prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 1, issue: 'never locks' }], carried: [], resolved_previous: [] });
14
+ let PR = { number: 7, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'desc' };
15
+ const VERDICT = JSON.stringify({ prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 1, issue: 'never locks', consequence: 'a second run reads stale rows' }], carried: [], resolved_previous: [] });
16
16
  /** Real git; a pull request on the branch; a claude that answers one finding. */
17
17
  const exec = async (command, args, options) => {
18
18
  if (command === 'git') {
@@ -76,6 +76,20 @@ describe('the background review', () => {
76
76
  expect(status).toMatchObject({ branch: 'feature', last: { head, mode: 'full', open: [expect.objectContaining({ issue: 'never locks' })] } });
77
77
  expect(status?.running).toBeUndefined();
78
78
  });
79
+ it('asks whether a review is due before checking anything out, and the status says why it was skipped', async () => {
80
+ PR = { ...PR, isDraft: true };
81
+ try {
82
+ const head = git('rev-parse', 'HEAD');
83
+ const result = await backgroundReview(repo, { head, branch: 'feature', base: 'main' }, config, exec, () => undefined);
84
+ expect(result).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('is a draft') });
85
+ const store = (await VerdictStore.open(repo, exec));
86
+ expect(fs.existsSync(store.worktreeDir(head))).toBe(false); // never checked out
87
+ expect((await reviewStatus(repo, 'feature', exec))?.attempt).toMatchObject({ head, outcome: 'skipped', reason: expect.stringContaining('is a draft') });
88
+ }
89
+ finally {
90
+ PR = { ...PR, isDraft: false };
91
+ }
92
+ });
79
93
  it('starts detached with a pid the status reports, and a newer start for the branch ends the older one', async () => {
80
94
  const head = git('rev-parse', 'HEAD');
81
95
  const sleeper = [process.execPath, '-e', 'setTimeout(() => {}, 20000)'];