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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +7 -4
  2. package/dist/cli-help.test.d.ts +0 -1
  3. package/dist/cli-help.test.js +0 -27
  4. package/dist/commands/brief.test.d.ts +0 -1
  5. package/dist/commands/brief.test.js +0 -134
  6. package/dist/commands/deep-provider.test.d.ts +0 -1
  7. package/dist/commands/deep-provider.test.js +0 -19
  8. package/dist/commands/deep.test.d.ts +0 -1
  9. package/dist/commands/deep.test.js +0 -46
  10. package/dist/commands/demo.test.d.ts +0 -1
  11. package/dist/commands/demo.test.js +0 -59
  12. package/dist/commands/doctor.test.d.ts +0 -1
  13. package/dist/commands/doctor.test.js +0 -20
  14. package/dist/commands/exit-codes.test.d.ts +0 -1
  15. package/dist/commands/exit-codes.test.js +0 -20
  16. package/dist/commands/export-training-sites.test.d.ts +0 -1
  17. package/dist/commands/export-training-sites.test.js +0 -25
  18. package/dist/commands/hooks-check-repos.test.d.ts +0 -1
  19. package/dist/commands/hooks-check-repos.test.js +0 -33
  20. package/dist/commands/hooks-git.test.d.ts +0 -1
  21. package/dist/commands/hooks-git.test.js +0 -64
  22. package/dist/commands/hooks-push.test.d.ts +0 -1
  23. package/dist/commands/hooks-push.test.js +0 -89
  24. package/dist/commands/hooks-stop.test.d.ts +0 -1
  25. package/dist/commands/hooks-stop.test.js +0 -167
  26. package/dist/commands/hooks.test.d.ts +0 -1
  27. package/dist/commands/hooks.test.js +0 -256
  28. package/dist/commands/init-mcp-spec.test.d.ts +0 -1
  29. package/dist/commands/init-mcp-spec.test.js +0 -10
  30. package/dist/commands/init.test.d.ts +0 -1
  31. package/dist/commands/init.test.js +0 -107
  32. package/dist/commands/learn.test.d.ts +0 -1
  33. package/dist/commands/learn.test.js +0 -50
  34. package/dist/commands/personal.test.d.ts +0 -1
  35. package/dist/commands/personal.test.js +0 -191
  36. package/dist/commands/repo-setup.test.d.ts +0 -1
  37. package/dist/commands/repo-setup.test.js +0 -47
  38. package/dist/commands/review-checked.test.d.ts +0 -1
  39. package/dist/commands/review-checked.test.js +0 -50
  40. package/dist/commands/review-diff-tests.test.d.ts +0 -1
  41. package/dist/commands/review-diff-tests.test.js +0 -20
  42. package/dist/commands/review-human.test.d.ts +0 -1
  43. package/dist/commands/review-human.test.js +0 -94
  44. package/dist/commands/review-post.test.d.ts +0 -1
  45. package/dist/commands/review-post.test.js +0 -157
  46. package/dist/commands/review-pr-body.test.d.ts +0 -1
  47. package/dist/commands/review-pr-body.test.js +0 -19
  48. package/dist/commands/review-receipt.test.d.ts +0 -1
  49. package/dist/commands/review-receipt.test.js +0 -16
  50. package/dist/commands/review-reviewer.test.d.ts +0 -1
  51. package/dist/commands/review-reviewer.test.js +0 -68
  52. package/dist/commands/review-scope.test.d.ts +0 -1
  53. package/dist/commands/review-scope.test.js +0 -43
  54. package/dist/commands/review-stats.test.d.ts +0 -1
  55. package/dist/commands/review-stats.test.js +0 -19
  56. package/dist/commands/review-stdin.test.d.ts +0 -1
  57. package/dist/commands/review-stdin.test.js +0 -21
  58. package/dist/commands/review-summary.test.d.ts +0 -1
  59. package/dist/commands/review-summary.test.js +0 -46
  60. package/dist/commands/review-task.test.d.ts +0 -1
  61. package/dist/commands/review-task.test.js +0 -47
  62. package/dist/commands/scan-rules.test.d.ts +0 -1
  63. package/dist/commands/scan-rules.test.js +0 -29
  64. package/dist/commands/scan.test.d.ts +0 -1
  65. package/dist/commands/scan.test.js +0 -64
  66. package/dist/commands/skills.test.d.ts +0 -1
  67. package/dist/commands/skills.test.js +0 -56
  68. package/dist/commands/studio-activity.test.d.ts +0 -1
  69. package/dist/commands/studio-activity.test.js +0 -47
  70. package/dist/commands/studio-checkouts.test.d.ts +0 -1
  71. package/dist/commands/studio-checkouts.test.js +0 -23
  72. package/dist/commands/studio-context.test.d.ts +0 -1
  73. package/dist/commands/studio-context.test.js +0 -26
  74. package/dist/commands/studio-contracts.test.d.ts +0 -1
  75. package/dist/commands/studio-contracts.test.js +0 -8
  76. package/dist/commands/studio-guard.test.d.ts +0 -1
  77. package/dist/commands/studio-guard.test.js +0 -24
  78. package/dist/commands/studio-learned-rules.test.d.ts +0 -1
  79. package/dist/commands/studio-learned-rules.test.js +0 -31
  80. package/dist/commands/studio-learning.test.d.ts +0 -1
  81. package/dist/commands/studio-learning.test.js +0 -41
  82. package/dist/commands/studio-progress.test.d.ts +0 -1
  83. package/dist/commands/studio-progress.test.js +0 -41
  84. package/dist/commands/studio-reviewer.test.d.ts +0 -1
  85. package/dist/commands/studio-reviewer.test.js +0 -92
  86. package/dist/commands/studio-week.test.d.ts +0 -1
  87. package/dist/commands/studio-week.test.js +0 -32
  88. package/dist/commands/thread.test.d.ts +0 -1
  89. package/dist/commands/thread.test.js +0 -32
  90. package/dist/commands/uninstall.test.d.ts +0 -1
  91. package/dist/commands/uninstall.test.js +0 -104
  92. package/dist/init-rules.test.d.ts +0 -1
  93. package/dist/init-rules.test.js +0 -55
  94. package/dist/smoke.test.d.ts +0 -1
  95. package/dist/smoke.test.js +0 -82
  96. package/dist/utils/cli-version.test.d.ts +0 -1
  97. package/dist/utils/cli-version.test.js +0 -9
  98. package/dist/utils/version.test.d.ts +0 -1
  99. package/dist/utils/version.test.js +0 -22
@@ -1,191 +0,0 @@
1
- import { execFileSync, spawnSync } 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, vi } from 'vitest';
6
- import { asUserLevel, disableHere, enabledHere, enableHere } from './personal.js';
7
- import { setupCommand } from './setup.js';
8
- import { uninstall } from './uninstall.js';
9
- let repo;
10
- let home;
11
- let bin;
12
- const saved = { RIGOUR_AGENT_HOME: process.env.RIGOUR_AGENT_HOME, RIGOUR_CLAUDE_CLI: process.env.RIGOUR_CLAUDE_CLI, RIGOUR_HOME: process.env.RIGOUR_HOME };
13
- const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
14
- const atHome = (rel) => path.join(home, rel);
15
- const unix = process.platform !== 'win32';
16
- /** A `claude` CLI that keeps its MCP servers in a file, so add/get/remove behave like the real one. */
17
- function fakeClaude() {
18
- const servers = path.join(bin, 'claude-mcp.txt');
19
- fs.writeFileSync(path.join(bin, 'claude'), `#!/bin/sh
20
- echo "$@" >> "${path.join(bin, 'claude-calls.log')}"
21
- case "$1 $2" in
22
- "--version "*) echo "9.9.9 (Claude Code)"; exit 0 ;;
23
- "mcp get") grep -qx "$3" "${servers}" 2>/dev/null; exit $? ;;
24
- "mcp add") echo "$5" >> "${servers}"; exit 0 ;;
25
- "mcp remove") : > "${servers}"; exit 0 ;;
26
- esac
27
- exit 1
28
- `, { mode: 0o755 });
29
- process.env.RIGOUR_CLAUDE_CLI = path.join(bin, 'claude');
30
- return path.join(bin, 'claude-calls.log');
31
- }
32
- beforeEach(() => {
33
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'personal-repo-'));
34
- home = fs.mkdtempSync(path.join(os.tmpdir(), 'personal-home-'));
35
- bin = fs.mkdtempSync(path.join(os.tmpdir(), 'personal-bin-'));
36
- // The agents' home and Rigour's own, both throwaway (os.homedir() would ignore a HOME set here).
37
- process.env.RIGOUR_AGENT_HOME = home;
38
- process.env.RIGOUR_HOME = home;
39
- git('init', '-q', '-b', 'main');
40
- git('config', 'core.hooksPath', '.git/hooks');
41
- git('config', 'user.email', 't@example.com');
42
- git('config', 'user.name', 't');
43
- git('config', 'commit.gpgsign', 'false');
44
- fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ name: 'app' }));
45
- fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
46
- git('add', '-A');
47
- git('commit', '-qm', 'init');
48
- vi.spyOn(console, 'log').mockImplementation(() => undefined);
49
- vi.spyOn(console, 'error').mockImplementation(() => undefined);
50
- vi.spyOn(console, 'warn').mockImplementation(() => undefined);
51
- });
52
- afterEach(() => {
53
- vi.restoreAllMocks();
54
- for (const [key, value] of Object.entries(saved)) {
55
- if (value === undefined)
56
- delete process.env[key];
57
- else
58
- process.env[key] = value;
59
- }
60
- for (const dir of [repo, home, bin])
61
- fs.rmSync(dir, { recursive: true, force: true });
62
- });
63
- describe('the switch for a repository', () => {
64
- it('lives in the git directory, shared by worktrees, and puts .rigour/ in info/exclude once', () => {
65
- expect(enabledHere(repo)).toBe(false);
66
- enableHere(repo);
67
- enableHere(repo);
68
- expect(enabledHere(repo)).toBe(true);
69
- expect(fs.readFileSync(path.join(repo, '.git/info/exclude'), 'utf8').split('\n').filter(l => l === '.rigour/')).toHaveLength(1);
70
- const worktree = path.join(os.tmpdir(), `personal-wt-${process.pid}`);
71
- git('worktree', 'add', '-q', '--detach', worktree);
72
- try {
73
- expect(enabledHere(worktree)).toBe(true);
74
- }
75
- finally {
76
- git('worktree', 'remove', '--force', worktree);
77
- }
78
- disableHere(repo, true);
79
- expect(enabledHere(repo)).toBe(false);
80
- expect(fs.readFileSync(path.join(repo, '.git/info/exclude'), 'utf8')).not.toContain('.rigour/');
81
- });
82
- });
83
- describe.skipIf(!unix)('the guard on a machine-level hook', () => {
84
- /** The command a project Stop hook becomes in the user-level Claude settings. */
85
- const guarded = (command) => JSON.parse(asUserLevel({ path: '.claude/settings.json', content: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } }) }).content).hooks.Stop[0].hooks[0].command;
86
- const run = (command) => spawnSync('sh', ['-c', command], { cwd: repo, input: 'payload', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: '' } });
87
- it('runs the command only in a repository switched on, with stdin passed through, even for a quoted command', () => {
88
- const command = guarded(`sh -c 'printf "ran:"; cat'`);
89
- expect(run(command).stdout).toBe('');
90
- expect(run(command).status).toBe(0);
91
- enableHere(repo);
92
- expect(run(command).stdout).toBe('ran:payload');
93
- const outside = spawnSync('sh', ['-c', command], { cwd: os.tmpdir(), input: 'x', encoding: 'utf8' });
94
- expect(outside).toMatchObject({ status: 0, stdout: '' });
95
- });
96
- it('turns each project hook file into its agent\'s user-level twin, every command guarded', () => {
97
- const claude = asUserLevel({ path: '.claude/settings.json', content: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'npx rigour hooks stop' }] }] } }) });
98
- expect(claude.path).toBe('.claude/settings.json');
99
- expect(JSON.parse(claude.content).hooks.Stop[0].hooks[0].command).toMatch(/^sh -c '.*rigour-enabled.*exec npx rigour hooks stop'$/);
100
- expect(asUserLevel({ path: '.windsurf/hooks.json', content: '{}' }).path).toBe('.codeium/windsurf/hooks.json');
101
- const cline = asUserLevel({ path: '.clinerules/hooks/PostToolUse', content: '#!/usr/bin/env node\nprocess.stdout.write("ran");\n' });
102
- expect(cline.path).toBe('Documents/Cline/Hooks/PostToolUse');
103
- fs.writeFileSync(path.join(bin, 'hook.js'), cline.content);
104
- expect(spawnSync(process.execPath, [path.join(bin, 'hook.js')], { cwd: repo, encoding: 'utf8' }).stdout).toBe('{}');
105
- enableHere(repo);
106
- expect(spawnSync(process.execPath, [path.join(bin, 'hook.js')], { cwd: repo, encoding: 'utf8' }).stdout).toBe('ran');
107
- });
108
- });
109
- describe.skipIf(!unix)('rigour setup, personal', () => {
110
- it('leaves the working tree untouched, installs machine hooks into the configs the person has, and comes back out exactly', async () => {
111
- const calls = fakeClaude();
112
- const ownSettings = { permissions: { allow: ['Bash(ls)'] }, hooks: { PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'npm run format' }] }] } };
113
- fs.mkdirSync(atHome('.claude'), { recursive: true });
114
- fs.writeFileSync(atHome('.claude/settings.json'), JSON.stringify(ownSettings));
115
- fs.mkdirSync(atHome('.cursor')); // Cursor is installed here; Windsurf and Cline are not
116
- await setupCommand(repo, { semantic: false });
117
- expect(git('status', '--porcelain', '--untracked-files=all')).toBe(''); // nothing in the working tree
118
- expect(enabledHere(repo)).toBe(true);
119
- expect(fs.readFileSync(path.join(repo, '.git/hooks/pre-push'), 'utf8')).toContain('hooks push --git');
120
- const settings = JSON.parse(fs.readFileSync(atHome('.claude/settings.json'), 'utf8'));
121
- expect(settings.permissions).toEqual(ownSettings.permissions);
122
- const commands = JSON.stringify(settings.hooks);
123
- expect(commands).toContain('npm run format');
124
- expect(commands).toContain('rigour-enabled'); // every Rigour hook is guarded
125
- for (const rel of ['.cursor/hooks.json', '.cursor/mcp.json'])
126
- expect(fs.existsSync(atHome(rel)), rel).toBe(true);
127
- for (const rel of ['.codeium', 'Documents'])
128
- expect(fs.existsSync(atHome(rel)), rel).toBe(false); // nothing for an agent that is not installed
129
- expect(fs.readFileSync(calls, 'utf8')).toMatch(/^mcp add --scope user rigour -- /m);
130
- // Setting up a second repository reuses the machine install: one Rigour entry per event, not two.
131
- await setupCommand(repo, { semantic: false });
132
- const again = JSON.parse(fs.readFileSync(atHome('.claude/settings.json'), 'utf8'));
133
- expect(again.hooks.Stop).toHaveLength(1);
134
- // This repository off: the machine hooks stay, silent here now.
135
- uninstall(repo);
136
- expect(enabledHere(repo)).toBe(false);
137
- expect(fs.existsSync(path.join(repo, '.git/hooks/pre-push'))).toBe(false);
138
- expect(fs.existsSync(atHome('.cursor/hooks.json'))).toBe(true);
139
- // Off the machine: the person's own settings are back as they were.
140
- uninstall(repo, { machine: true });
141
- expect(JSON.parse(fs.readFileSync(atHome('.claude/settings.json'), 'utf8'))).toEqual(ownSettings);
142
- for (const rel of ['.cursor/hooks.json', '.codeium/windsurf/hooks.json', 'Documents/Cline/Hooks/PostToolUse', '.cursor/mcp.json']) {
143
- expect(fs.existsSync(atHome(rel))).toBe(false);
144
- }
145
- expect(fs.readFileSync(calls, 'utf8')).toMatch(/^mcp remove --scope user rigour$/m);
146
- expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
147
- });
148
- });
149
- describe('rigour setup --team', () => {
150
- it('commits the configuration, without instruction files or empty documents unless asked', async () => {
151
- await setupCommand(repo, { semantic: false, team: true });
152
- expect(fs.existsSync(path.join(repo, 'rigour.yml'))).toBe(true);
153
- expect(fs.readFileSync(path.join(repo, '.claude/settings.json'), 'utf8')).toContain('hooks check');
154
- for (const rel of ['CLAUDE.md', 'AGENTS.md', 'docs/AGENT_INSTRUCTIONS.md', 'docs/SPEC.md', 'docs/ARCH.md'])
155
- expect(fs.existsSync(path.join(repo, rel))).toBe(false);
156
- expect(enabledHere(repo)).toBe(false); // machine hooks stay silent beside the committed ones
157
- });
158
- it('writes instruction files where the project has none when asked', async () => {
159
- await setupCommand(repo, { semantic: false, team: true, instructions: true });
160
- expect(fs.existsSync(path.join(repo, 'CLAUDE.md'))).toBe(true);
161
- });
162
- it("leaves a teammate's clone exactly as committed, and still writes instructions when asked", async () => {
163
- await setupCommand(repo, { semantic: false, team: true });
164
- git('add', '-A');
165
- git('commit', '-qm', 'adopt rigour');
166
- await setupCommand(repo, { semantic: false }); // a teammate after cloning: rigour.yml is tracked
167
- expect(git('status', '--porcelain')).toBe(''); // no committed file changed
168
- expect(fs.readFileSync(path.join(repo, '.git/hooks/pre-push'), 'utf8')).toContain('hooks push --git');
169
- await setupCommand(repo, { semantic: false, instructions: true });
170
- expect(fs.readFileSync(path.join(repo, 'CLAUDE.md'), 'utf8')).toBe('@AGENTS.md\n');
171
- });
172
- it('keeps a .cursor/mcp.json that is not valid JSON', async () => {
173
- fs.mkdirSync(path.join(repo, '.cursor'));
174
- fs.writeFileSync(path.join(repo, '.cursor/mcp.json'), '{ not json');
175
- await setupCommand(repo, { semantic: false, team: true });
176
- expect(fs.readFileSync(path.join(repo, '.cursor/mcp.json'), 'utf8')).toBe('{ not json');
177
- });
178
- });
179
- describe('rigour setup, personal, with git hooks kept in the repository', () => {
180
- it('leaves a committed hooks folder (Husky) alone and says what to add', async () => {
181
- fs.mkdirSync(path.join(repo, '.husky'));
182
- fs.writeFileSync(path.join(repo, '.husky/pre-push'), 'npm test\n');
183
- git('add', '-A');
184
- git('commit', '-qm', 'husky');
185
- git('config', 'core.hooksPath', '.husky');
186
- await setupCommand(repo, { semantic: false });
187
- expect(fs.readFileSync(path.join(repo, '.husky/pre-push'), 'utf8')).toBe('npm test\n');
188
- expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
189
- expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain('hooks push --git "$@" || exit $?');
190
- });
191
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,47 +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 { checkRepoSetup } from './repo-setup.js';
7
- let cwd;
8
- const write = (rel, body) => { fs.mkdirSync(path.dirname(path.join(cwd, rel)), { recursive: true }); fs.writeFileSync(path.join(cwd, rel), body); };
9
- beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-')); });
10
- afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
11
- const now = new Date('2026-10-09T12:00:00Z');
12
- const byId = async (id) => (await checkRepoSetup(cwd, now, [])).find(c => c.id === id);
13
- describe('checkRepoSetup', () => {
14
- it('says working only when the hook fired this week, not because a file exists', async () => {
15
- write('.claude/settings.json', JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ command: 'rigour hooks check --stdin' }] }] } }));
16
- expect(await byId('edit')).toMatchObject({ state: 'set up', detail: 'Configured; has not fired here yet' });
17
- write('.rigour/events.jsonl', JSON.stringify({ type: 'hook_check', timestamp: '2026-10-09T11:50:00Z' }) + '\n');
18
- expect(await byId('edit')).toMatchObject({ state: 'working', detail: '1 edit check this week · last 10 min ago' });
19
- });
20
- it('calls out the old hook that checked nothing', async () => {
21
- write('.claude/settings.json', 'rigour hooks check --files "$TOOL_INPUT_file_path"');
22
- expect(await byId('edit')).toMatchObject({ state: 'broken', fix: 'rigour hooks init --force' });
23
- });
24
- it('finds the PR workflow and reports what is missing', async () => {
25
- expect((await byId('pr')).state).toBe('missing');
26
- expect((await byId('config')).state).toBe('missing');
27
- write('.github/workflows/review.yml', 'steps:\n - uses: rigour-labs/rigour@v6\n');
28
- expect(await byId('pr')).toMatchObject({ state: 'set up', detail: '.github/workflows/review.yml' });
29
- });
30
- it('counts what agents did in every worktree of the repository', async () => {
31
- const git = (...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
32
- git('init', '-q', '-b', 'main');
33
- git('-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init');
34
- const tree = path.join(cwd, 'wt');
35
- git('worktree', 'add', '-q', tree);
36
- write('.claude/settings.json', 'rigour hooks check --stdin; rigour hooks stop --tool claude');
37
- fs.mkdirSync(path.join(tree, '.rigour'));
38
- fs.writeFileSync(path.join(tree, '.rigour', 'events.jsonl'), JSON.stringify({ type: 'stop_review', timestamp: '2026-10-09T11:00:00Z' }) + '\n');
39
- expect(await byId('stop')).toMatchObject({ state: 'working', detail: '1 finish check this week · last 1 h ago' });
40
- });
41
- it("counts the MCP server's tool calls, not the CLI's own events", async () => {
42
- write('.rigour/events.jsonl', JSON.stringify({ type: 'tool_call', tool: 'rigour_init', timestamp: '2026-10-09T11:00:00Z' }) + '\n');
43
- expect((await checkRepoSetup(cwd, now, [])).find(c => c.id === 'mcp').state).toBe('missing');
44
- const call = { toolName: 'rigour_review', cacheStatus: 'none', candidateTokens: 0, returnedTokens: 0, createdAt: Date.parse('2026-10-09T11:30:00Z') };
45
- expect((await checkRepoSetup(cwd, now, [call])).find(c => c.id === 'mcp')).toMatchObject({ state: 'working', detail: '1 tool call this week · last 30 min ago' });
46
- });
47
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,50 +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 { whatWasChecked } from './review-checked.js';
7
- let repo;
8
- const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
9
- const result = { report: { summary: { 'hallucinated-imports': 'PASS', 'unused-exports': 'FAIL' } } };
10
- beforeEach(() => {
11
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'checked-'));
12
- git('init', '-q', '-b', 'main');
13
- git('config', 'user.email', 't@example.com');
14
- git('config', 'user.name', 't');
15
- git('config', 'commit.gpgsign', 'false');
16
- fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
17
- git('add', '-A');
18
- git('commit', '-qm', 'init');
19
- });
20
- afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
21
- describe('what a review checked', () => {
22
- it('names the commits compared, whether uncommitted work was in it, the settings and every check', () => {
23
- const base = git('rev-parse', 'HEAD');
24
- git('checkout', '-q', '-b', 'feature');
25
- fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 2;\n');
26
- git('commit', '-qam', 'change');
27
- fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 3;\n'); // not committed
28
- expect(whatWasChecked(repo, { base: 'main' }, undefined, result)).toMatchObject({
29
- rigour_version: expect.stringMatching(/^\d+\.\d+\.\d+/),
30
- base: 'main',
31
- base_sha: base,
32
- head_sha: git('rev-parse', 'HEAD'),
33
- uncommitted: true,
34
- config: 'defaults',
35
- checks: { 'hallucinated-imports': 'PASS', 'unused-exports': 'FAIL' },
36
- });
37
- });
38
- it('says which rigour.yml it read: the working tree, the base for an independent review, or a file named by -c', () => {
39
- fs.writeFileSync(path.join(repo, 'rigour.yml'), 'gates: {}\n');
40
- expect(whatWasChecked(repo, {}, undefined, result)).toMatchObject({ base: null, base_sha: git('rev-parse', 'HEAD'), uncommitted: false, config: 'rigour.yml' });
41
- const base = git('rev-parse', 'HEAD'); // rigour.yml is not committed there
42
- expect(whatWasChecked(repo, { base: 'main' }, base, result).config).toBe('defaults');
43
- git('add', '-A');
44
- git('commit', '-qm', 'config');
45
- expect(whatWasChecked(repo, { base: 'main' }, git('rev-parse', 'HEAD'), result).config).toBe(`rigour.yml at ${git('rev-parse', 'HEAD')}`);
46
- fs.mkdirSync(path.join(repo, 'ci'));
47
- fs.writeFileSync(path.join(repo, 'ci/strict.yml'), 'gates: {}\n');
48
- expect(whatWasChecked(repo, { config: 'ci/strict.yml' }, undefined, result).config).toBe('ci/strict.yml');
49
- });
50
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,20 +0,0 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { reviewCommand } from './review.js';
6
- describe('rigour review --diff-tests', () => {
7
- let dir;
8
- afterEach(() => { if (dir)
9
- fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; vi.restoreAllMocks(); });
10
- it('refuses to run without a model that can propose test inputs', async () => {
11
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diff-tests-cli-'));
12
- fs.writeFileSync(path.join(dir, 'change.diff'), '');
13
- const out = [];
14
- vi.spyOn(console, 'log').mockImplementation((line) => { out.push(line); });
15
- const exit = vi.spyOn(process, 'exit').mockImplementation(((code) => { throw new Error(`exit ${code}`); }));
16
- await expect(reviewCommand(dir, { json: true, diff: 'change.diff', deep: true, diffTests: true })).rejects.toThrow('exit 2');
17
- expect(exit).toHaveBeenCalledWith(2);
18
- expect(JSON.parse(out[0])).toEqual({ error: 'INPUT_ERROR', message: expect.stringContaining('--diff-tests needs a model') });
19
- });
20
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,94 +0,0 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { printHuman } from './review-human.js';
6
- let dir;
7
- let out;
8
- beforeEach(() => {
9
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-human-'));
10
- out = [];
11
- vi.spyOn(console, 'log').mockImplementation((...args) => void out.push(args.join(' ')));
12
- });
13
- afterEach(() => {
14
- vi.restoreAllMocks();
15
- fs.rmSync(dir, { recursive: true, force: true });
16
- });
17
- const finding = (n, over = {}) => ({
18
- id: 'unused-export', title: `export f${n} is used nowhere`, details: '', files: ['src/a.ts'], line: n, hint: 'Remove the export.', ...over,
19
- });
20
- const result = (over = {}) => ({
21
- status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {},
22
- unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines: { 'src/a.ts': new Set([1]), 'src/b.ts': new Set([2]) },
23
- report: { status: 'PASS', failures: [] }, gateErrors: [], controlFilesChanged: [], hints: [], ...over,
24
- });
25
- const receipt = (over = {}) => ({
26
- functions: 3, reviewed: 0, changedSinceReview: 0, lowRisk: 1, notCovered: [{ file: 'src/a.ts', function: 'f', start: 1, score: 4 }],
27
- reviewers: {}, otherFiles: 0, setAside: 0, ...over,
28
- });
29
- const show = (r, over = {}) => {
30
- printHuman(r, { cwd: dir, scope: 'this branch against main', commits: 10, ms: 3200, receipt: null, ...over });
31
- return out.join('\n');
32
- };
33
- describe('rigour review, as a person reads it', () => {
34
- it('says what it reviewed, then the verdict', () => {
35
- const text = show(result());
36
- expect(text).toContain('Rigour reviewed this branch against main: 10 commits, 2 files, 3 s.');
37
- expect(text).toContain('✔ Nothing to fix in what this changed.');
38
- });
39
- it('lists at most five findings with where, what and the fix, and points at the rest', () => {
40
- const text = show(result({ status: 'FAIL', findings: [1, 2, 3, 4, 5, 6, 7].map(n => finding(n)) }));
41
- expect(text).toContain('✘ 7 things to fix before this is ready');
42
- expect(text).toContain('src/a.ts:5 export f5 is used nowhere');
43
- expect(text).not.toContain('export f6');
44
- expect(text).toContain('→ Remove the export.');
45
- expect(text).toContain('rigour dismiss');
46
- expect(text).toContain('…and 2 more: rigour review --all');
47
- });
48
- it('lists every finding with --all', () => {
49
- const text = show(result({ status: 'FAIL', findings: [1, 2, 3, 4, 5, 6, 7].map(n => finding(n)) }), { all: true });
50
- expect(text).toContain('export f7 is used nowhere');
51
- expect(text).not.toContain('--all');
52
- });
53
- it('is not finished, with the project\'s own install command, when the type checks need dependencies', () => {
54
- fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
55
- const text = show(result({ status: 'ERROR', gateErrors: ['typed-checks-unavailable'], typedError: "tsconfig.json cannot be loaded: File 'x' not found." }));
56
- expect(text).toContain('⚠ Not finished: the type checks need your dependencies installed');
57
- expect(text).toContain('Run pnpm install, then rigour review again. Everything else found nothing.');
58
- expect(text).not.toContain('Nothing to fix');
59
- });
60
- it('names a check that could not run for another reason', () => {
61
- fs.mkdirSync(path.join(dir, 'node_modules'));
62
- const text = show(result({ status: 'ERROR', gateErrors: ['typed-checks-unavailable'], typedError: 'tsconfig.json cannot be loaded: bad JSON' }));
63
- expect(text).toContain('⚠ Not finished: typed-checks-unavailable could not run (tsconfig.json cannot be loaded: bad JSON)');
64
- });
65
- it('keeps notes and old-code issues to one quiet line each', () => {
66
- const text = show(result({ advisory: [finding(1)], fileFindings: [finding(2)], preexisting: 12, excludedOutsideChangedLines: 3 }));
67
- expect(text).toContain('Also seen, never blocking: 2 notes (rigour review --notes)');
68
- expect(text).toContain('Not shown: 15 issues the code already had before this change');
69
- expect(text).not.toContain('export f1');
70
- expect(show(result({ advisory: [finding(1)] }), { notes: true })).toContain('src/a.ts:1 export f1 is used nowhere');
71
- });
72
- it('counts the hints a person should confirm, and lists them with --notes', () => {
73
- const hint = 'nested-scan src/a.ts:9: match() scans its `rows` argument and is called inside a loop; confirm the sizes or index the inner collection once';
74
- expect(show(result({ hints: [hint] }))).toContain('To confirm by hand: 1 hint (rigour review --notes)');
75
- out = [];
76
- expect(show(result({ hints: [hint] }), { notes: true })).toContain(` ${hint}`);
77
- });
78
- it('hides the receipt until agents have reviewed something, unless asked', () => {
79
- expect(show(result(), { receipt: receipt() })).not.toContain('Quality receipt');
80
- out = [];
81
- expect(show(result(), { receipt: receipt(), showReceipt: true })).toContain('Quality receipt');
82
- out = [];
83
- expect(show(result(), { receipt: receipt({ reviewed: 1 }) })).toContain('Quality receipt');
84
- });
85
- it('offers setup once, only where Rigour is not set up', () => {
86
- expect(show(result())).toContain('Next: rigour setup checks this while your agent works and before every push.');
87
- out = [];
88
- fs.writeFileSync(path.join(dir, 'rigour.yml'), 'version: 1\n');
89
- expect(show(result())).not.toContain('Next:');
90
- });
91
- it('says so when there is nothing to review', () => {
92
- expect(show(result({ report: null }))).toBe('No changes to review.');
93
- });
94
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,157 +0,0 @@
1
- import { buildRecord } from '@rigour-labs/core';
2
- import { describe, expect, it } from 'vitest';
3
- import { findingKey, postReview, rankFindings, receiptLines, summaryBody } from './review-post.js';
4
- const target = { token: 't', repo: 'acme/app', pr: 7, sha: 'abc123' };
5
- const BOT = { login: 'github-actions[bot]' };
6
- function finding(over) {
7
- return { id: 'x', gate: 'Gate', severity: 'medium', provenance: 'traditional', message: 'm', file: 'src/a.ts', line: 10, ...over };
8
- }
9
- /** A GitHub that remembers comments, and refuses inline reviews when told to. */
10
- function fakeGitHub(state, refuseInline = false) {
11
- const calls = [];
12
- const fetchImpl = async (url, init = {}) => {
13
- const path = url.replace('https://api.github.com/repos/acme/app', '').replace(/\?.*$/, '');
14
- const method = init.method ?? 'GET';
15
- const body = init.body ? JSON.parse(init.body) : undefined;
16
- calls.push({ method, path, body });
17
- const reply = (ok, json = {}) => ({ ok, status: ok ? 200 : 422, json: async () => json, text: async () => '' });
18
- const query = new URL(url).searchParams;
19
- const page = (items) => {
20
- const size = Number(query.get('per_page') ?? 30);
21
- const from = (Number(query.get('page') ?? 1) - 1) * size;
22
- return items.slice(from, from + size);
23
- };
24
- if (method === 'GET' && path === '/pulls/7/comments')
25
- return reply(true, page(state.review));
26
- if (method === 'GET' && path === '/issues/7/comments')
27
- return reply(true, page(state.issue));
28
- if (method === 'POST' && path === '/pulls/7/reviews') {
29
- if (refuseInline)
30
- return reply(false);
31
- state.review.push(...body.comments.map((c) => ({ body: c.body, user: BOT })));
32
- return reply(true);
33
- }
34
- if (method === 'POST' && path === '/issues/7/comments') {
35
- state.issue.push({ id: state.issue.length + 1, body: body.body, user: BOT });
36
- return reply(true);
37
- }
38
- const patched = method === 'PATCH' && state.issue.find(c => path === `/issues/comments/${c.id}`);
39
- if (patched) {
40
- patched.body = body.body;
41
- return reply(true);
42
- }
43
- return reply(false);
44
- };
45
- return { fetchImpl, calls };
46
- }
47
- const report = {
48
- status: 'FAIL',
49
- failures: [
50
- finding({ gate: 'Style', severity: 'low', message: 'naming' }),
51
- finding({ gate: 'Race', severity: 'high', provenance: 'deep-analysis', message: 'check-then-act', line: 30, anchor_line: 32 }),
52
- finding({ gate: 'Leak', severity: 'high', provenance: 'security', message: 'token in log' }),
53
- finding({ gate: 'NoLine', line: null }),
54
- ],
55
- deep: { model: 'anthropic/claude-sonnet-5.5', cost_usd: 0.0421, router: { routed: 3, functions: 11, already_reviewed: 2 } },
56
- };
57
- describe('the summary with a review record', () => {
58
- it('carries the record: blocks, should-fixes, counts, judges and the hash', () => {
59
- const record = buildRecord({ head: 'abcdef0123456789', base: '0123456789abcdef', scope: 'full', verdict: { prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [], carried: [], resolved_previous: [] },
60
- accounted: { open: [{ id: 'i1', kind: 'finding', class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' }], advisory: [], unverified: [], notes: [], resolved: [], answerInReply: [], disputed: [], dismissed: [] },
61
- judges: [{ reviewer: 'claude', cost_usd: 0.5 }], lessonsServed: 0, humanReviews: 0 });
62
- const body = summaryBody({ ...report, reviewer: { record } }, 1, 1, 0);
63
- expect(body).toContain('**Review record** · 1 blocking · 0 should-fix');
64
- expect(body).toContain('- **Blocking** `src/job.ts:2` returns before the lock');
65
- expect(body).toContain(`Integrity \`${record.integrity.slice(0, 16)}\``);
66
- expect(summaryBody(report, 1, 1, 0)).not.toContain('Review record');
67
- });
68
- });
69
- describe('rankFindings', () => {
70
- it('puts severity first, then the most provable source', () => {
71
- expect(rankFindings(report.failures).map(f => f.gate)).toEqual(['Leak', 'Race', 'NoLine', 'Style']);
72
- });
73
- });
74
- describe('postReview', () => {
75
- it('posts at most two inline comments on their anchor lines, and one summary', async () => {
76
- const state = { review: [], issue: [] };
77
- const { fetchImpl, calls } = fakeGitHub(state);
78
- const result = await postReview(report, target, 2, fetchImpl);
79
- const review = calls.find(c => c.path === '/pulls/7/reviews').body;
80
- expect(review.commit_id).toBe('abc123');
81
- expect(review.comments.map((c) => [c.path, c.line])).toEqual([['src/a.ts', 10], ['src/a.ts', 32]]);
82
- expect(review.comments[1].body).toContain('root cause at line 30');
83
- expect(result).toMatchObject({ inline: 2, summaryUpdated: true });
84
- expect(state.issue[0].body).toContain('2 already reviewed before the PR');
85
- expect(state.issue[0].body).toContain('cost $0.042');
86
- });
87
- it('never re-posts a finding on the next push, and edits the summary instead of adding one', async () => {
88
- const state = { review: [], issue: [] };
89
- await postReview(report, target, 2, fakeGitHub(state).fetchImpl);
90
- const { fetchImpl, calls } = fakeGitHub(state);
91
- const second = await postReview(report, target, 2, fetchImpl);
92
- const reposted = calls.find(c => c.path === '/pulls/7/reviews').body.comments;
93
- // Leak and Race went out on the first push; only Style is new.
94
- expect(reposted).toHaveLength(1);
95
- expect(reposted[0].body).toContain(findingKey(report.failures[0]));
96
- expect(second.skippedAlreadyPosted).toBe(2);
97
- expect(calls.some(c => c.method === 'PATCH')).toBe(true);
98
- expect(state.issue).toHaveLength(1);
99
- });
100
- it('finds its summary on a later page of a long conversation, and edits it instead of adding one', async () => {
101
- const chatter = Array.from({ length: 150 }, (_, i) => ({ id: i + 1, body: `comment ${i}`, user: { login: 'someone' } }));
102
- chatter[120] = { id: 121, body: '<!-- rigour:summary -->\nold', user: BOT };
103
- const state = { review: [], issue: chatter };
104
- const { fetchImpl, calls } = fakeGitHub(state);
105
- await postReview(report, target, 2, fetchImpl);
106
- expect(calls.filter(c => c.method === 'POST' && c.path === '/issues/7/comments')).toHaveLength(0);
107
- expect(calls.some(c => c.method === 'PATCH' && c.path === '/issues/comments/121')).toBe(true);
108
- });
109
- it('still posts the summary when GitHub refuses an inline comment', async () => {
110
- const state = { review: [], issue: [] };
111
- const result = await postReview(report, target, 2, fakeGitHub(state, true).fetchImpl);
112
- expect(result).toMatchObject({ inline: 0, summaryUpdated: true });
113
- expect(state.issue[0].body).toContain('0 posted inline now');
114
- });
115
- it('trusts only its own comments: a marker or summary written by anyone else changes nothing', async () => {
116
- const author = { login: 'pr-author' };
117
- const forged = report.failures.map(f => ({ body: `<!-- rigour:finding:${findingKey(f)} -->`, user: author }));
118
- const state = { review: forged, issue: [{ id: 1, body: '<!-- rigour:summary -->', user: author }] };
119
- const { fetchImpl, calls } = fakeGitHub(state);
120
- const result = await postReview(report, target, 2, fetchImpl);
121
- expect(result).toMatchObject({ inline: 2, skippedAlreadyPosted: 0 });
122
- expect(calls.some(c => c.method === 'PATCH')).toBe(false);
123
- expect(state.issue.find(c => c.user === BOT)?.body).toContain('Rigour review: FAIL');
124
- });
125
- it('says when findings were dismissed and when the PR edits Rigour settings', async () => {
126
- const state = { review: [], issue: [] };
127
- await postReview({ status: 'PASS', failures: [], dismissed: 1, control_files_changed: ['.rigour/dismissed.json'] }, target, 2, fakeGitHub(state).fetchImpl);
128
- expect(state.issue[0].body).toContain('1 finding(s) on changed lines were dismissed');
129
- expect(state.issue[0].body).toContain('`.rigour/dismissed.json`');
130
- });
131
- it('shows the quality receipt: what was reviewed before the PR and what was not', () => {
132
- const lines = receiptLines({
133
- functions: 12, reviewed: 7, changed_since_review: 1, low_risk: 3, set_aside: 0,
134
- not_covered: [
135
- { file: 'src/refund.ts', function: 'issueRefund', line: 40, changed_since_review: true },
136
- { file: 'src/pay.ts', function: 'formatTotal', line: 3, changed_since_review: false, lesson: 'Format money from integer cents.' },
137
- ],
138
- });
139
- expect(lines[0]).toBe('**Quality receipt:** 12 changed functions · 7 reviewed before this PR · 1 changed after review · 3 low risk · **2 not covered**');
140
- expect(lines.slice(1)).toEqual([
141
- '- `issueRefund` in `src/refund.ts:40`: changed after its review',
142
- '- `formatTotal` in `src/pay.ts:3`: matches a team lesson: Format money from integer cents.',
143
- ]);
144
- expect(receiptLines({ functions: 2, reviewed: 0, changed_since_review: 0, low_risk: 0, set_aside: 2, not_covered: [] })[1]).toContain('not counted: this check is independent');
145
- });
146
- });
147
- describe('reviewPostCommand', () => {
148
- it('says why when the review did not run, and posts nothing', async () => {
149
- const { reviewPostCommand } = await import('./review-post.js');
150
- const fs = await import('fs');
151
- const os = await import('os');
152
- const path = await import('path');
153
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'post-')), 'report.json');
154
- fs.writeFileSync(file, JSON.stringify({ error: 'rigour.yml: gates.ast.complexity: Expected number' }));
155
- await expect(reviewPostCommand({ report: file })).rejects.toThrow('The review did not run, so there is nothing to post: rigour.yml: gates.ast.complexity');
156
- });
157
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,19 +0,0 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { afterEach, describe, expect, it } from 'vitest';
5
- import { readPrBody } from './review.js';
6
- let dir;
7
- afterEach(() => { if (dir)
8
- fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; });
9
- describe('readPrBody', () => {
10
- it('prefers --pr-body, else reads the pull request of a GitHub Actions event, else nothing', () => {
11
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-body-'));
12
- fs.writeFileSync(path.join(dir, 'body.md'), 'Fix the redirect loop.');
13
- fs.writeFileSync(path.join(dir, 'event.json'), JSON.stringify({ pull_request: { title: 'fix: encode spaces', body: 'Paths with spaces looped.' } }));
14
- const event = { GITHUB_EVENT_PATH: path.join(dir, 'event.json') };
15
- expect(readPrBody(dir, { prBody: 'body.md' }, event)).toBe('Fix the redirect loop.');
16
- expect(readPrBody(dir, {}, event)).toBe('fix: encode spaces\n\nPaths with spaces looped.');
17
- expect(readPrBody(dir, {}, {})).toBeUndefined();
18
- });
19
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { receiptFor, receiptSummary } from './review-receipt.js';
3
- const receipt = (over) => ({
4
- functions: 4, reviewed: 1, changedSinceReview: 0, lowRisk: 2, notCovered: [], reviewers: {}, otherFiles: 0, setAside: 0, ...over,
5
- });
6
- describe('quality receipt line', () => {
7
- it('counts what is known, and mentions changes after review only when there are some', () => {
8
- expect(receiptSummary(receipt({}))).toBe('4 changed functions · 1 reviewed before this · 2 low risk · 0 not covered');
9
- const gap = { file: 'a.ts', function: 'f', start: 3, score: 4, changedSinceReview: true };
10
- expect(receiptSummary(receipt({ functions: 1, reviewed: 0, lowRisk: 0, changedSinceReview: 1, notCovered: [gap] })))
11
- .toBe('1 changed function · 0 reviewed before this · 1 changed after review · 0 low risk · 1 not covered');
12
- });
13
- it('has no receipt without a diff', () => {
14
- expect(receiptFor(process.cwd(), undefined, { gates: {} }, false)).toBeNull();
15
- });
16
- });
@@ -1 +0,0 @@
1
- export {};