@rigour-labs/cli 6.8.0 → 6.8.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.
- package/package.json +7 -4
- package/dist/cli-help.test.d.ts +0 -1
- package/dist/cli-help.test.js +0 -27
- package/dist/commands/brief.test.d.ts +0 -1
- package/dist/commands/brief.test.js +0 -134
- package/dist/commands/deep-provider.test.d.ts +0 -1
- package/dist/commands/deep-provider.test.js +0 -19
- package/dist/commands/deep.test.d.ts +0 -1
- package/dist/commands/deep.test.js +0 -46
- package/dist/commands/demo.test.d.ts +0 -1
- package/dist/commands/demo.test.js +0 -59
- package/dist/commands/doctor.test.d.ts +0 -1
- package/dist/commands/doctor.test.js +0 -20
- package/dist/commands/exit-codes.test.d.ts +0 -1
- package/dist/commands/exit-codes.test.js +0 -20
- package/dist/commands/export-training-sites.test.d.ts +0 -1
- package/dist/commands/export-training-sites.test.js +0 -25
- package/dist/commands/hooks-check-repos.test.d.ts +0 -1
- package/dist/commands/hooks-check-repos.test.js +0 -33
- package/dist/commands/hooks-git.test.d.ts +0 -1
- package/dist/commands/hooks-git.test.js +0 -64
- package/dist/commands/hooks-push.test.d.ts +0 -1
- package/dist/commands/hooks-push.test.js +0 -89
- package/dist/commands/hooks-stop.test.d.ts +0 -1
- package/dist/commands/hooks-stop.test.js +0 -167
- package/dist/commands/hooks.test.d.ts +0 -1
- package/dist/commands/hooks.test.js +0 -256
- package/dist/commands/init-mcp-spec.test.d.ts +0 -1
- package/dist/commands/init-mcp-spec.test.js +0 -10
- package/dist/commands/init.test.d.ts +0 -1
- package/dist/commands/init.test.js +0 -107
- package/dist/commands/learn.test.d.ts +0 -1
- package/dist/commands/learn.test.js +0 -50
- package/dist/commands/personal.test.d.ts +0 -1
- package/dist/commands/personal.test.js +0 -191
- package/dist/commands/repo-setup.test.d.ts +0 -1
- package/dist/commands/repo-setup.test.js +0 -47
- package/dist/commands/review-checked.test.d.ts +0 -1
- package/dist/commands/review-checked.test.js +0 -50
- package/dist/commands/review-diff-tests.test.d.ts +0 -1
- package/dist/commands/review-diff-tests.test.js +0 -20
- package/dist/commands/review-human.test.d.ts +0 -1
- package/dist/commands/review-human.test.js +0 -94
- package/dist/commands/review-post.test.d.ts +0 -1
- package/dist/commands/review-post.test.js +0 -157
- package/dist/commands/review-pr-body.test.d.ts +0 -1
- package/dist/commands/review-pr-body.test.js +0 -19
- package/dist/commands/review-receipt.test.d.ts +0 -1
- package/dist/commands/review-receipt.test.js +0 -16
- package/dist/commands/review-reviewer.test.d.ts +0 -1
- package/dist/commands/review-reviewer.test.js +0 -68
- package/dist/commands/review-scope.test.d.ts +0 -1
- package/dist/commands/review-scope.test.js +0 -43
- package/dist/commands/review-stats.test.d.ts +0 -1
- package/dist/commands/review-stats.test.js +0 -19
- package/dist/commands/review-stdin.test.d.ts +0 -1
- package/dist/commands/review-stdin.test.js +0 -21
- package/dist/commands/review-summary.test.d.ts +0 -1
- package/dist/commands/review-summary.test.js +0 -46
- package/dist/commands/review-task.test.d.ts +0 -1
- package/dist/commands/review-task.test.js +0 -47
- package/dist/commands/scan-rules.test.d.ts +0 -1
- package/dist/commands/scan-rules.test.js +0 -29
- package/dist/commands/scan.test.d.ts +0 -1
- package/dist/commands/scan.test.js +0 -64
- package/dist/commands/skills.test.d.ts +0 -1
- package/dist/commands/skills.test.js +0 -56
- package/dist/commands/studio-activity.test.d.ts +0 -1
- package/dist/commands/studio-activity.test.js +0 -47
- package/dist/commands/studio-checkouts.test.d.ts +0 -1
- package/dist/commands/studio-checkouts.test.js +0 -23
- package/dist/commands/studio-context.test.d.ts +0 -1
- package/dist/commands/studio-context.test.js +0 -26
- package/dist/commands/studio-contracts.test.d.ts +0 -1
- package/dist/commands/studio-contracts.test.js +0 -8
- package/dist/commands/studio-guard.test.d.ts +0 -1
- package/dist/commands/studio-guard.test.js +0 -24
- package/dist/commands/studio-learned-rules.test.d.ts +0 -1
- package/dist/commands/studio-learned-rules.test.js +0 -31
- package/dist/commands/studio-learning.test.d.ts +0 -1
- package/dist/commands/studio-learning.test.js +0 -41
- package/dist/commands/studio-progress.test.d.ts +0 -1
- package/dist/commands/studio-progress.test.js +0 -41
- package/dist/commands/studio-reviewer.test.d.ts +0 -1
- package/dist/commands/studio-reviewer.test.js +0 -92
- package/dist/commands/studio-week.test.d.ts +0 -1
- package/dist/commands/studio-week.test.js +0 -32
- package/dist/commands/thread.test.d.ts +0 -1
- package/dist/commands/thread.test.js +0 -32
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -104
- package/dist/init-rules.test.d.ts +0 -1
- package/dist/init-rules.test.js +0 -55
- package/dist/smoke.test.d.ts +0 -1
- package/dist/smoke.test.js +0 -82
- package/dist/utils/cli-version.test.d.ts +0 -1
- package/dist/utils/cli-version.test.js +0 -9
- package/dist/utils/version.test.d.ts +0 -1
- package/dist/utils/version.test.js +0 -22
|
@@ -1,167 +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, vi } from 'vitest';
|
|
6
|
-
import { readThread, recordSessionBaseline } from '@rigour-labs/core';
|
|
7
|
-
import { hooksStopCommand } from './hooks-stop.js';
|
|
8
|
-
// A credential header on a redirect-following request: a proven (verified) high finding.
|
|
9
|
-
const LEAKY = "export async function notify(endpoint: string, signature: string) {\n return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });\n}\n";
|
|
10
|
-
// Only a heuristic finding (fetch without error handling), not proven.
|
|
11
|
-
const HEURISTIC = "export async function load(url: string) {\n return fetch(url);\n}\n";
|
|
12
|
-
describe('rigour hooks stop', () => {
|
|
13
|
-
let repo;
|
|
14
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
15
|
-
const write = (rel, body) => {
|
|
16
|
-
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
17
|
-
fs.writeFileSync(path.join(repo, rel), body);
|
|
18
|
-
};
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'stop-hook-'));
|
|
21
|
-
git('init', '-q', '-b', 'main');
|
|
22
|
-
git('config', 'user.email', 't@example.com');
|
|
23
|
-
git('config', 'user.name', 't');
|
|
24
|
-
git('config', 'commit.gpgsign', 'false');
|
|
25
|
-
write('rigour.yml', 'version: 1\ngates:\n semantic_bugs:\n enabled: true\n unused_exports:\n block: true\n'); // this team blocks on dead code
|
|
26
|
-
write('.gitignore', '.rigour/\n');
|
|
27
|
-
git('add', '-A');
|
|
28
|
-
git('commit', '-qm', 'init');
|
|
29
|
-
});
|
|
30
|
-
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); vi.restoreAllMocks(); });
|
|
31
|
-
it('keeps Claude working on a proven finding, then gives up after three attempts', async () => {
|
|
32
|
-
write('src/notify.ts', LEAKY);
|
|
33
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 's1' });
|
|
34
|
-
const first = JSON.parse(await hooksStopCommand('claude', payload, '/'));
|
|
35
|
-
expect(first.decision).toBe('block');
|
|
36
|
-
expect(first.reason).toContain('src/notify.ts:2');
|
|
37
|
-
expect(first.reason).toContain('attempt 1 of 3');
|
|
38
|
-
// The task's thread keeps the blocked stop review, with the agent and its session.
|
|
39
|
-
expect(readThread(repo)?.events.map(e => [e.kind, e.agent, e.session, e.blocked])).toEqual([['stop-review', 'claude', 's1', true]]);
|
|
40
|
-
for (const n of [1, 2]) {
|
|
41
|
-
write('src/notify.ts', `${LEAKY}// attempt ${n}\n`); // the agent edits, still leaking
|
|
42
|
-
await hooksStopCommand('claude', payload, '/');
|
|
43
|
-
}
|
|
44
|
-
write('src/notify.ts', `${LEAKY}// attempt 3\n`);
|
|
45
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
46
|
-
});
|
|
47
|
-
it('sends Cursor a follow-up message, and stops following up at the loop limit', async () => {
|
|
48
|
-
write('src/notify.ts', LEAKY);
|
|
49
|
-
const reply = JSON.parse(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'completed', loop_count: 0 }), '/'));
|
|
50
|
-
expect(reply.followup_message).toContain('src/notify.ts:2');
|
|
51
|
-
expect(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'completed', loop_count: 3 }), '/')).toBe('');
|
|
52
|
-
expect(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'aborted' }), '/')).toBe('');
|
|
53
|
-
});
|
|
54
|
-
it('asks once, at the end of the task, about what the team learned that applies to the change', async () => {
|
|
55
|
-
const lesson = { id: 'l1', text: 'Time out every fetch the loader makes.', file: 'src/load.ts', symbols: ['load'], state: 'verified', evidence: [{ pr: 7, comment: 'c1', author: 'senior' }, { pr: 9, comment: 'c2', author: 'senior' }], createdAt: '2026-01-01', updatedAt: '2026-01-01' };
|
|
56
|
-
write('.rigour/review-lessons.json', JSON.stringify({ version: 1, lessons: [lesson, { ...lesson, id: 'l2', text: 'Unrelated lesson about billing.', file: 'src/billing.ts', symbols: ['charge'] }] }));
|
|
57
|
-
write('src/load.ts', HEURISTIC);
|
|
58
|
-
write('src/main.ts', "import { load } from './load';\nvoid load('/');\n");
|
|
59
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'teach' });
|
|
60
|
-
const first = JSON.parse(await hooksStopCommand('claude', payload, '/'));
|
|
61
|
-
expect(first.decision).toBe('block');
|
|
62
|
-
expect(first.reason).toContain('src/load.ts: Time out every fetch the loader makes. (acted on in PR #7, #9)');
|
|
63
|
-
expect(first.reason).not.toContain('billing');
|
|
64
|
-
write('src/load.ts', `${HEURISTIC}// the agent checked it\n`);
|
|
65
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe(''); // asked once: never a loop
|
|
66
|
-
});
|
|
67
|
-
it('lets the agent stop when only heuristic findings remain', async () => {
|
|
68
|
-
write('src/load.ts', HEURISTIC);
|
|
69
|
-
write('src/main.ts', "import { load } from './load';\nvoid load('/');\n");
|
|
70
|
-
write('package.json', '{"scripts":{"start":"node src/main.ts"}}\n'); // wired in: not dead code
|
|
71
|
-
expect(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's2' }), '/')).toBe('');
|
|
72
|
-
});
|
|
73
|
-
it('says once when the review cannot run, then lets the agent stop', async () => {
|
|
74
|
-
const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'stop-hook-norepo-'));
|
|
75
|
-
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
76
|
-
const payload = JSON.stringify({ cwd: notARepo, session_id: 'broken' });
|
|
77
|
-
const first = JSON.parse(await hooksStopCommand('claude', payload, '/'));
|
|
78
|
-
expect(first).toMatchObject({ decision: 'block' });
|
|
79
|
-
expect(first.reason).toContain('Rigour could not review this change');
|
|
80
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
81
|
-
fs.rmSync(notARepo, { recursive: true, force: true });
|
|
82
|
-
});
|
|
83
|
-
it('still reviews what the session committed', async () => {
|
|
84
|
-
recordSessionBaseline(repo, 's3');
|
|
85
|
-
write('src/notify.ts', LEAKY);
|
|
86
|
-
git('add', '-A');
|
|
87
|
-
git('commit', '-qm', 'hide it in a commit');
|
|
88
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's3' }), '/'));
|
|
89
|
-
expect(reply.reason).toContain('src/notify.ts:2');
|
|
90
|
-
});
|
|
91
|
-
it('reviews the whole branch, including commits made before the session started', async () => {
|
|
92
|
-
git('checkout', '-q', '-b', 'feature');
|
|
93
|
-
write('src/notify.ts', LEAKY);
|
|
94
|
-
git('add', '-A');
|
|
95
|
-
git('commit', '-qm', 'earlier work on the branch');
|
|
96
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'branch' }), '/'));
|
|
97
|
-
expect(reply.reason).toContain('src/notify.ts:2');
|
|
98
|
-
});
|
|
99
|
-
it('says there was nothing to review instead of passing silently', async () => {
|
|
100
|
-
git('checkout', '-q', '-b', 'empty');
|
|
101
|
-
const said = [];
|
|
102
|
-
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
|
103
|
-
expect(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'empty' }), '/')).toBe('');
|
|
104
|
-
expect(said.join('')).toContain('nothing to review against main @');
|
|
105
|
-
});
|
|
106
|
-
it('holds the agent back on an export the branch added that nothing uses', async () => {
|
|
107
|
-
git('checkout', '-q', '-b', 'dead');
|
|
108
|
-
write('src/util.ts', 'export const used = 1;\nexport const forgotten = 2;\n');
|
|
109
|
-
write('src/main.ts', "import { used } from './util';\nconsole.log(used);\n");
|
|
110
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'dead' }), '/'));
|
|
111
|
-
expect(reply.reason).toContain('src/util.ts:2 Unused export');
|
|
112
|
-
});
|
|
113
|
-
it('does not repeat itself after a turn that changed nothing, and reviews again after an edit', async () => {
|
|
114
|
-
write('src/notify.ts', LEAKY);
|
|
115
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'quiet' });
|
|
116
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
117
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe(''); // a read-only turn: already said
|
|
118
|
-
write('src/notify.ts', LEAKY + '// touched\n');
|
|
119
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
120
|
-
});
|
|
121
|
-
it("does not count Rigour's own files as new work where the repository does not ignore them", async () => {
|
|
122
|
-
write('.gitignore', ''); // a repository that does not ignore .rigour/ or the report
|
|
123
|
-
git('add', '-A');
|
|
124
|
-
git('commit', '-qm', 'ignore nothing');
|
|
125
|
-
write('src/notify.ts', LEAKY);
|
|
126
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'own-files' });
|
|
127
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
128
|
-
write('rigour-report.json', '{"rewritten":true}\n'); // what a review leaves behind
|
|
129
|
-
write('.rigour/scan-cache.json', '{}\n');
|
|
130
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
131
|
-
});
|
|
132
|
-
it('ignores an attempt counter written into the workspace', async () => {
|
|
133
|
-
write('.rigour/stop-hook.json', JSON.stringify({ s4: 3 }));
|
|
134
|
-
write('src/notify.ts', LEAKY);
|
|
135
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's4' }), '/'));
|
|
136
|
-
expect(reply.reason).toContain('attempt 1 of 3');
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
describe('the agent fix loop', () => {
|
|
140
|
-
it('turns an agent fix to a stop-hook finding into a validated rule', async () => {
|
|
141
|
-
const { learnCommand } = await import('./learn.js');
|
|
142
|
-
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-'));
|
|
143
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
144
|
-
git('init', '-q', '-b', 'main');
|
|
145
|
-
git('config', 'user.email', 't@example.com');
|
|
146
|
-
git('config', 'user.name', 't');
|
|
147
|
-
git('config', 'commit.gpgsign', 'false');
|
|
148
|
-
fs.writeFileSync(path.join(repo, '.gitignore'), '.rigour/\n');
|
|
149
|
-
git('add', '-A');
|
|
150
|
-
git('commit', '-qm', 'init');
|
|
151
|
-
fs.mkdirSync(path.join(repo, 'src'));
|
|
152
|
-
fs.writeFileSync(path.join(repo, 'src/notify.ts'), LEAKY);
|
|
153
|
-
fs.writeFileSync(path.join(repo, 'package.json'), '{"scripts":{"notify":"node src/notify.ts"}}\n'); // wired in: not dead code
|
|
154
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'loop' });
|
|
155
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
156
|
-
fs.writeFileSync(path.join(repo, 'src/notify.ts'), LEAKY.replace("{ method: 'POST',", "{ method: 'POST', redirect: 'manual',"));
|
|
157
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
158
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
159
|
-
await learnCommand(repo, undefined, { agentFixes: true });
|
|
160
|
-
const rules = fs.readdirSync(path.join(repo, '.rigour', 'rules'));
|
|
161
|
-
expect(rules).toHaveLength(1);
|
|
162
|
-
expect(JSON.parse(fs.readFileSync(path.join(repo, '.rigour', 'rules', rules[0]), 'utf8')).pattern)
|
|
163
|
-
.toMatchObject({ template: 'require-option', property: 'redirect', value: 'manual' });
|
|
164
|
-
expect(fs.readdirSync(path.join(repo, '.rigour', 'agent-fixes', 'resolved'))).toEqual([]);
|
|
165
|
-
fs.rmSync(repo, { recursive: true, force: true });
|
|
166
|
-
});
|
|
167
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for hooks init command.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import { execFileSync } from 'child_process';
|
|
6
|
-
import { readThread } from '@rigour-labs/core';
|
|
7
|
-
import { hooksInitCommand, hooksCheckCommand, parseStdinFiles } from './hooks.js';
|
|
8
|
-
import * as fs from 'fs';
|
|
9
|
-
import * as path from 'path';
|
|
10
|
-
import * as os from 'os';
|
|
11
|
-
import yaml from 'yaml';
|
|
12
|
-
vi.mock('@rigour-labs/core', async (importOriginal) => {
|
|
13
|
-
const actual = await importOriginal();
|
|
14
|
-
return {
|
|
15
|
-
...actual,
|
|
16
|
-
recordInteractionEvidence: vi.fn().mockResolvedValue(undefined),
|
|
17
|
-
updateAutomaticIndexForFiles: vi.fn().mockResolvedValue(undefined),
|
|
18
|
-
};
|
|
19
|
-
});
|
|
20
|
-
describe('hooksInitCommand', () => {
|
|
21
|
-
let testDir;
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'));
|
|
24
|
-
// Write minimal rigour.yml
|
|
25
|
-
fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({
|
|
26
|
-
version: 1,
|
|
27
|
-
gates: { max_file_lines: 500 },
|
|
28
|
-
}));
|
|
29
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
30
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
31
|
-
});
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
34
|
-
vi.restoreAllMocks();
|
|
35
|
-
});
|
|
36
|
-
it('should generate Claude hooks', async () => {
|
|
37
|
-
await hooksInitCommand(testDir, { tool: 'claude' });
|
|
38
|
-
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
39
|
-
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
40
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
41
|
-
expect(settings.hooks).toBeDefined();
|
|
42
|
-
expect(settings.hooks.PostToolUse).toBeDefined();
|
|
43
|
-
expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain('hooks check');
|
|
44
|
-
// Claude Code sends the edited path as JSON on stdin; it exports no TOOL_INPUT_* variable.
|
|
45
|
-
expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain('--stdin');
|
|
46
|
-
expect(settings.hooks.PostToolUse[0].hooks[0].command).not.toContain('TOOL_INPUT');
|
|
47
|
-
});
|
|
48
|
-
it('reads the edited file from the JSON Claude Code sends on stdin', () => {
|
|
49
|
-
expect(parseStdinFiles(JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Write', tool_input: { file_path: 'src/a.ts', content: 'x' } }))).toEqual(['src/a.ts']);
|
|
50
|
-
});
|
|
51
|
-
it('should generate Cursor hooks', async () => {
|
|
52
|
-
await hooksInitCommand(testDir, { tool: 'cursor' });
|
|
53
|
-
const hooksPath = path.join(testDir, '.cursor', 'hooks.json');
|
|
54
|
-
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
55
|
-
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
56
|
-
expect(hooks.hooks).toBeDefined();
|
|
57
|
-
});
|
|
58
|
-
it('should generate Cline hooks', async () => {
|
|
59
|
-
await hooksInitCommand(testDir, { tool: 'cline' });
|
|
60
|
-
const hookPath = path.join(testDir, '.clinerules', 'hooks', 'PostToolUse');
|
|
61
|
-
expect(fs.existsSync(hookPath)).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
it('should skip a legacy .clinerules file and continue other tools', async () => {
|
|
64
|
-
fs.writeFileSync(path.join(testDir, '.clinerules'), 'legacy Cline rules');
|
|
65
|
-
await expect(hooksInitCommand(testDir, {
|
|
66
|
-
tool: 'cline,windsurf',
|
|
67
|
-
})).resolves.toBeUndefined();
|
|
68
|
-
expect(fs.readFileSync(path.join(testDir, '.clinerules'), 'utf-8'))
|
|
69
|
-
.toBe('legacy Cline rules');
|
|
70
|
-
expect(fs.existsSync(path.join(testDir, '.windsurf', 'hooks.json'))).toBe(true);
|
|
71
|
-
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('SKIP .clinerules/hooks/PostToolUse'));
|
|
72
|
-
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Cline: Not configured'));
|
|
73
|
-
});
|
|
74
|
-
it('should generate Windsurf hooks', async () => {
|
|
75
|
-
await hooksInitCommand(testDir, { tool: 'windsurf' });
|
|
76
|
-
const hooksPath = path.join(testDir, '.windsurf', 'hooks.json');
|
|
77
|
-
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
it('should support dry-run mode', async () => {
|
|
80
|
-
await hooksInitCommand(testDir, { tool: 'claude', dryRun: true });
|
|
81
|
-
// Dry run should NOT create files
|
|
82
|
-
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
83
|
-
expect(fs.existsSync(settingsPath)).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
it('merges into a settings file the person already has, keeping their settings and hooks, and stays idempotent', async () => {
|
|
86
|
-
const claudeDir = path.join(testDir, '.claude');
|
|
87
|
-
fs.mkdirSync(claudeDir, { recursive: true });
|
|
88
|
-
const own = { permissions: { allow: ['Bash(ls)'] }, hooks: { PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'npm run format' }] }] } };
|
|
89
|
-
fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify(own));
|
|
90
|
-
for (const force of [false, true, true])
|
|
91
|
-
await hooksInitCommand(testDir, { tool: 'claude', force });
|
|
92
|
-
const merged = JSON.parse(fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf-8'));
|
|
93
|
-
expect(merged.permissions).toEqual(own.permissions);
|
|
94
|
-
const postToolUse = merged.hooks.PostToolUse.flatMap((group) => group.hooks.map((h) => h.command));
|
|
95
|
-
expect(postToolUse[0]).toBe('npm run format');
|
|
96
|
-
expect(postToolUse.filter((c) => /hooks check/.test(c))).toHaveLength(1); // three runs, one Rigour entry
|
|
97
|
-
expect(merged.hooks.Stop).toHaveLength(1);
|
|
98
|
-
});
|
|
99
|
-
it('leaves a settings file that is not valid JSON alone', async () => {
|
|
100
|
-
const claudeDir = path.join(testDir, '.claude');
|
|
101
|
-
fs.mkdirSync(claudeDir, { recursive: true });
|
|
102
|
-
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{ not json');
|
|
103
|
-
await hooksInitCommand(testDir, { tool: 'claude', force: true });
|
|
104
|
-
expect(fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf-8')).toBe('{ not json');
|
|
105
|
-
});
|
|
106
|
-
it('should propagate --block to generated hook commands', async () => {
|
|
107
|
-
await hooksInitCommand(testDir, { tool: 'all', force: true, block: true });
|
|
108
|
-
const claude = JSON.parse(fs.readFileSync(path.join(testDir, '.claude', 'settings.json'), 'utf-8'));
|
|
109
|
-
const cursor = JSON.parse(fs.readFileSync(path.join(testDir, '.cursor', 'hooks.json'), 'utf-8'));
|
|
110
|
-
const windsurf = JSON.parse(fs.readFileSync(path.join(testDir, '.windsurf', 'hooks.json'), 'utf-8'));
|
|
111
|
-
const clineScript = fs.readFileSync(path.join(testDir, '.clinerules', 'hooks', 'PostToolUse'), 'utf-8');
|
|
112
|
-
expect(claude.hooks.PostToolUse[0].hooks[0].command).toContain('--block');
|
|
113
|
-
expect(cursor.hooks.afterFileEdit[0].command).toContain('--block');
|
|
114
|
-
expect(windsurf.hooks.post_write_code[0].command).toContain('--block');
|
|
115
|
-
expect(clineScript).toContain('--block');
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
describe('hooksInitCommand — DLP integration', () => {
|
|
119
|
-
let testDir;
|
|
120
|
-
beforeEach(() => {
|
|
121
|
-
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-dlp-test-'));
|
|
122
|
-
fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({
|
|
123
|
-
version: 1,
|
|
124
|
-
gates: { max_file_lines: 500 },
|
|
125
|
-
}));
|
|
126
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
127
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
128
|
-
});
|
|
129
|
-
afterEach(() => {
|
|
130
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
131
|
-
vi.restoreAllMocks();
|
|
132
|
-
});
|
|
133
|
-
it('should generate Claude hooks with DLP (PreToolUse) by default', async () => {
|
|
134
|
-
await hooksInitCommand(testDir, { tool: 'claude', force: true });
|
|
135
|
-
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
136
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
137
|
-
expect(settings.hooks.PostToolUse).toBeDefined();
|
|
138
|
-
const dlp = settings.hooks.PreToolUse.find((h) => h.matcher === '.*');
|
|
139
|
-
expect(dlp.hooks[0].command).toContain('--mode dlp');
|
|
140
|
-
expect(dlp.hooks[0].command).toMatch(/npx --yes @rigour-labs\/cli@\d+\.\d+\.\d+/);
|
|
141
|
-
const push = settings.hooks.PreToolUse.find((h) => h.matcher === 'Bash');
|
|
142
|
-
expect(push.hooks[0]).toMatchObject({ command: expect.stringContaining('hooks push --stdin'), timeout: 1800 });
|
|
143
|
-
expect(push.hooks[0].command).toContain('case "$payload" in *git*push*)'); // other commands never start Rigour
|
|
144
|
-
});
|
|
145
|
-
it('should generate Cursor hooks with DLP (beforeFileEdit) by default', async () => {
|
|
146
|
-
await hooksInitCommand(testDir, { tool: 'cursor', force: true });
|
|
147
|
-
const hooksPath = path.join(testDir, '.cursor', 'hooks.json');
|
|
148
|
-
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
149
|
-
expect(hooks.hooks.afterFileEdit).toBeDefined();
|
|
150
|
-
expect(hooks.hooks.beforeSubmitPrompt).toBeDefined();
|
|
151
|
-
});
|
|
152
|
-
it('should generate Windsurf hooks with DLP by default', async () => {
|
|
153
|
-
await hooksInitCommand(testDir, { tool: 'windsurf', force: true });
|
|
154
|
-
const hooksPath = path.join(testDir, '.windsurf', 'hooks.json');
|
|
155
|
-
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
156
|
-
expect(hooks.hooks.post_write_code).toBeDefined();
|
|
157
|
-
expect(hooks.hooks.pre_write_code).toBeDefined();
|
|
158
|
-
});
|
|
159
|
-
it('should skip DLP hooks when dlp: false', async () => {
|
|
160
|
-
await hooksInitCommand(testDir, { tool: 'claude', force: true, dlp: false });
|
|
161
|
-
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
162
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
163
|
-
expect(settings.hooks.PostToolUse).toBeDefined();
|
|
164
|
-
expect(settings.hooks.PreToolUse.map((h) => h.matcher)).toEqual(['Bash']); // the push gate stays
|
|
165
|
-
});
|
|
166
|
-
it('should generate Cline DLP warnings without default blocking', async () => {
|
|
167
|
-
await hooksInitCommand(testDir, { tool: 'cline', force: true });
|
|
168
|
-
const script = fs.readFileSync(path.join(testDir, '.clinerules', 'hooks', 'PreToolUse'), 'utf-8');
|
|
169
|
-
expect(script).toContain("result.status !== 'clean'");
|
|
170
|
-
expect(script).toContain("if (result.status === 'blocked') process.exit(2)");
|
|
171
|
-
});
|
|
172
|
-
it('should not route DLP hooks through the file-only core checker', async () => {
|
|
173
|
-
const coreChecker = path.join(testDir, 'node_modules/@rigour-labs/core/dist/hooks/standalone-checker.js');
|
|
174
|
-
fs.mkdirSync(path.dirname(coreChecker), { recursive: true });
|
|
175
|
-
fs.writeFileSync(coreChecker, '');
|
|
176
|
-
await hooksInitCommand(testDir, { tool: 'cursor', force: true });
|
|
177
|
-
const hooks = JSON.parse(fs.readFileSync(path.join(testDir, '.cursor', 'hooks.json'), 'utf-8'));
|
|
178
|
-
const command = hooks.hooks.beforeSubmitPrompt[0].command;
|
|
179
|
-
expect(command).toMatch(/npx --yes @rigour-labs\/cli@\d+\.\d+\.\d+/);
|
|
180
|
-
expect(command).not.toContain('standalone-checker');
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
describe('hooksCheckCommand', () => {
|
|
184
|
-
let testDir;
|
|
185
|
-
beforeEach(() => {
|
|
186
|
-
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-check-test-'));
|
|
187
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
188
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
189
|
-
});
|
|
190
|
-
afterEach(() => {
|
|
191
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
192
|
-
vi.restoreAllMocks();
|
|
193
|
-
});
|
|
194
|
-
it('should return pass JSON when file is clean', async () => {
|
|
195
|
-
const filePath = path.join(testDir, 'ok.ts');
|
|
196
|
-
fs.writeFileSync(filePath, 'export const x = 1;\n');
|
|
197
|
-
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
198
|
-
await hooksCheckCommand(testDir, { files: 'ok.ts' });
|
|
199
|
-
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join('');
|
|
200
|
-
expect(output).toContain('"status":"pass"');
|
|
201
|
-
});
|
|
202
|
-
it("keeps each edit check on the task's thread, with the files and the findings", async () => {
|
|
203
|
-
execFileSync('git', ['-C', testDir, 'init', '-q', '-b', 'feat/PROJ-21-thread']);
|
|
204
|
-
fs.writeFileSync(path.join(testDir, 'ok.ts'), 'export const x = 1;\n');
|
|
205
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
206
|
-
await hooksCheckCommand(testDir, { files: 'ok.ts', agent: 'codex' });
|
|
207
|
-
expect(readThread(testDir, 'feat/PROJ-21-thread')?.events.map(e => [e.kind, e.agent, e.files, e.findings, e.status])).toEqual([['edit-check', 'codex', ['ok.ts'], 0, 'pass']]);
|
|
208
|
-
});
|
|
209
|
-
it('reports skipped, not pass, when the hook named no file', async () => {
|
|
210
|
-
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
211
|
-
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
212
|
-
await hooksCheckCommand(testDir, { files: '' });
|
|
213
|
-
expect(stdoutSpy.mock.calls.map(call => String(call[0])).join('')).toContain('"status":"skipped"');
|
|
214
|
-
});
|
|
215
|
-
it('should return fail JSON and set exit code 2 in block mode', async () => {
|
|
216
|
-
const filePath = path.join(testDir, 'bad.ts');
|
|
217
|
-
fs.writeFileSync(filePath, "const password = 'abcdefghijklmnopqrstuvwxyz12345';\n");
|
|
218
|
-
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
219
|
-
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
220
|
-
const originalExitCode = process.exitCode;
|
|
221
|
-
await hooksCheckCommand(testDir, { files: 'bad.ts', block: true });
|
|
222
|
-
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join('');
|
|
223
|
-
expect(output).toContain('"status":"fail"');
|
|
224
|
-
expect(stderrSpy).toHaveBeenCalled();
|
|
225
|
-
expect(process.exitCode).toBe(2);
|
|
226
|
-
process.exitCode = originalExitCode;
|
|
227
|
-
});
|
|
228
|
-
it('should warn and continue for DLP detections by default', async () => {
|
|
229
|
-
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
230
|
-
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
231
|
-
const originalExitCode = process.exitCode;
|
|
232
|
-
await hooksCheckCommand(testDir, {
|
|
233
|
-
mode: 'dlp',
|
|
234
|
-
files: 'AKIAZ9Y8X7W6V5U4T3Q2',
|
|
235
|
-
});
|
|
236
|
-
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join('');
|
|
237
|
-
expect(output).toContain('"status":"warning"');
|
|
238
|
-
expect(output).not.toContain('AKIAZ9Y8X7W6V5U4T3Q2');
|
|
239
|
-
expect(stderrSpy).toHaveBeenCalled();
|
|
240
|
-
expect(process.exitCode).toBe(originalExitCode);
|
|
241
|
-
});
|
|
242
|
-
it('should block DLP detections only with --block', async () => {
|
|
243
|
-
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
244
|
-
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
245
|
-
const originalExitCode = process.exitCode;
|
|
246
|
-
await hooksCheckCommand(testDir, {
|
|
247
|
-
mode: 'dlp',
|
|
248
|
-
files: 'AKIAZ9Y8X7W6V5U4T3Q2',
|
|
249
|
-
block: true,
|
|
250
|
-
});
|
|
251
|
-
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join('');
|
|
252
|
-
expect(output).toContain('"status":"blocked"');
|
|
253
|
-
expect(process.exitCode).toBe(2);
|
|
254
|
-
process.exitCode = originalExitCode;
|
|
255
|
-
});
|
|
256
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { mcpPackageSpec } from './init.js';
|
|
3
|
-
describe('mcpPackageSpec', () => {
|
|
4
|
-
it("pins the MCP server to this CLI's major, never a bare or stale exact version", () => {
|
|
5
|
-
expect(mcpPackageSpec('6.5.2')).toBe('@rigour-labs/mcp@6');
|
|
6
|
-
expect(mcpPackageSpec('7.0.0-beta.1')).toBe('@rigour-labs/mcp@7');
|
|
7
|
-
expect(mcpPackageSpec('0.0.0')).toBe('@rigour-labs/mcp@latest');
|
|
8
|
-
expect(mcpPackageSpec('unknown')).toBe('@rigour-labs/mcp@latest');
|
|
9
|
-
});
|
|
10
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for init command — IDE detection, config generation, auto-hook integration.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import { initCommand } from './init.js';
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import * as os from 'os';
|
|
9
|
-
describe('initCommand', () => {
|
|
10
|
-
let testDir;
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'init-test-'));
|
|
13
|
-
// Minimal package.json for discovery
|
|
14
|
-
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({
|
|
15
|
-
name: 'test-project',
|
|
16
|
-
dependencies: { express: '^4.0.0' },
|
|
17
|
-
}));
|
|
18
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
19
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
20
|
-
});
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
23
|
-
vi.restoreAllMocks();
|
|
24
|
-
});
|
|
25
|
-
it('should create rigour.yml', async () => {
|
|
26
|
-
await initCommand(testDir);
|
|
27
|
-
const configPath = path.join(testDir, 'rigour.yml');
|
|
28
|
-
expect(fs.existsSync(configPath)).toBe(true);
|
|
29
|
-
const content = fs.readFileSync(configPath, 'utf-8');
|
|
30
|
-
expect(content).toContain('gates:');
|
|
31
|
-
expect(content).not.toMatch(/^version:|fast_gates|^planned:|input_validation|adaptive:/m); // settings nothing reads are not written
|
|
32
|
-
});
|
|
33
|
-
it('writes no instruction files or empty documents unless asked', async () => {
|
|
34
|
-
await initCommand(testDir);
|
|
35
|
-
for (const rel of ['docs/AGENT_INSTRUCTIONS.md', 'CLAUDE.md', 'AGENTS.md', '.windsurfrules', 'docs/SPEC.md', 'docs/ARCH.md']) {
|
|
36
|
-
expect(fs.existsSync(path.join(testDir, rel))).toBe(false);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
it('should support dry-run mode', async () => {
|
|
40
|
-
await initCommand(testDir, { dryRun: true });
|
|
41
|
-
// Dry run should NOT create rigour.yml
|
|
42
|
-
expect(fs.existsSync(path.join(testDir, 'rigour.yml'))).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
it('should not overwrite without --force', async () => {
|
|
45
|
-
// Create existing rigour.yml
|
|
46
|
-
fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nexisting: true\n');
|
|
47
|
-
await initCommand(testDir);
|
|
48
|
-
const content = fs.readFileSync(path.join(testDir, 'rigour.yml'), 'utf-8');
|
|
49
|
-
expect(content).toContain('existing');
|
|
50
|
-
});
|
|
51
|
-
it('should overwrite with --force', async () => {
|
|
52
|
-
// Create existing rigour.yml
|
|
53
|
-
fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nexisting: true\n');
|
|
54
|
-
await initCommand(testDir, { force: true });
|
|
55
|
-
// Should create backup
|
|
56
|
-
expect(fs.existsSync(path.join(testDir, 'rigour.yml.bak'))).toBe(true);
|
|
57
|
-
// New config should not contain 'existing'
|
|
58
|
-
const content = fs.readFileSync(path.join(testDir, 'rigour.yml'), 'utf-8');
|
|
59
|
-
expect(content).not.toContain('existing: true');
|
|
60
|
-
});
|
|
61
|
-
it('should detect Claude IDE and create hooks', async () => {
|
|
62
|
-
// Create Claude marker
|
|
63
|
-
fs.mkdirSync(path.join(testDir, '.claude'), { recursive: true });
|
|
64
|
-
await initCommand(testDir);
|
|
65
|
-
// Should have created .claude/settings.json (hooks)
|
|
66
|
-
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
67
|
-
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
it('should detect Cursor IDE and create hooks', async () => {
|
|
70
|
-
// Create Cursor marker
|
|
71
|
-
fs.mkdirSync(path.join(testDir, '.cursor'), { recursive: true });
|
|
72
|
-
await initCommand(testDir);
|
|
73
|
-
// Should have created .cursor/hooks.json
|
|
74
|
-
const hooksPath = path.join(testDir, '.cursor', 'hooks.json');
|
|
75
|
-
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
76
|
-
});
|
|
77
|
-
it('should update .gitignore with rigour patterns', async () => {
|
|
78
|
-
await initCommand(testDir);
|
|
79
|
-
const gitignorePath = path.join(testDir, '.gitignore');
|
|
80
|
-
expect(fs.existsSync(gitignorePath)).toBe(true);
|
|
81
|
-
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
82
|
-
expect(content).toContain('rigour-report.json');
|
|
83
|
-
expect(content).toContain('.rigour/*');
|
|
84
|
-
expect(content).toContain('!.rigour/dismissed.json');
|
|
85
|
-
expect(content).toContain('!.rigour/backtest.json');
|
|
86
|
-
});
|
|
87
|
-
it('turns an older whole-directory `.rigour/` ignore into one the shared files can escape', async () => {
|
|
88
|
-
fs.writeFileSync(path.join(testDir, '.gitignore'), 'node_modules\n.rigour/\n');
|
|
89
|
-
await initCommand(testDir);
|
|
90
|
-
const lines = fs.readFileSync(path.join(testDir, '.gitignore'), 'utf-8').split('\n');
|
|
91
|
-
expect(lines).not.toContain('.rigour/');
|
|
92
|
-
expect(lines.filter(l => l === '.rigour/*')).toHaveLength(1);
|
|
93
|
-
expect(lines).toContain('!.rigour/dismissed.json');
|
|
94
|
-
});
|
|
95
|
-
it('should create .rigour/memory.json for Studio', async () => {
|
|
96
|
-
await initCommand(testDir);
|
|
97
|
-
const memPath = path.join(testDir, '.rigour', 'memory.json');
|
|
98
|
-
expect(fs.existsSync(memPath)).toBe(true);
|
|
99
|
-
const mem = JSON.parse(fs.readFileSync(memPath, 'utf-8'));
|
|
100
|
-
expect(mem.memories.project_boot).toBeDefined();
|
|
101
|
-
});
|
|
102
|
-
it('should support --ide flag to target specific IDE', async () => {
|
|
103
|
-
await initCommand(testDir, { ide: 'windsurf' });
|
|
104
|
-
expect(fs.existsSync(path.join(testDir, '.windsurf', 'hooks.json'))).toBe(true);
|
|
105
|
-
expect(fs.existsSync(path.join(testDir, '.claude', 'settings.json'))).toBe(false);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,50 +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 { learnCommand } from './learn.js';
|
|
6
|
-
const BEFORE = "export async function post(url: string, init: RequestInit) {\n return fetch(url, init);\n}\n";
|
|
7
|
-
const AFTER = "export async function post(url: string, init: RequestInit) {\n return fetch(url, { ...init, redirect: 'manual' });\n}\n";
|
|
8
|
-
describe('learnCommand', () => {
|
|
9
|
-
let cwd;
|
|
10
|
-
let log;
|
|
11
|
-
let error;
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'learn-cli-'));
|
|
14
|
-
fs.mkdirSync(path.join(cwd, 'src'));
|
|
15
|
-
fs.writeFileSync(path.join(cwd, 'old.ts'), BEFORE);
|
|
16
|
-
fs.writeFileSync(path.join(cwd, 'src', 'http.ts'), AFTER);
|
|
17
|
-
log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
18
|
-
error = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
19
|
-
process.exitCode = undefined;
|
|
20
|
-
});
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
log.mockRestore();
|
|
23
|
-
error.mockRestore();
|
|
24
|
-
process.exitCode = undefined;
|
|
25
|
-
fs.rmSync(cwd, { recursive: true, force: true });
|
|
26
|
-
});
|
|
27
|
-
it('learns from --before/--after and saves the rule', async () => {
|
|
28
|
-
await learnCommand(cwd, undefined, { before: 'old.ts', after: 'src/http.ts' });
|
|
29
|
-
const saved = fs.readdirSync(path.join(cwd, '.rigour', 'rules'));
|
|
30
|
-
expect(saved).toHaveLength(1);
|
|
31
|
-
const rule = JSON.parse(fs.readFileSync(path.join(cwd, '.rigour', 'rules', saved[0]), 'utf8'));
|
|
32
|
-
expect(rule.pattern).toMatchObject({ template: 'require-option', property: 'redirect', value: 'manual' });
|
|
33
|
-
expect(rule.source).toEqual({ file: 'src/http.ts', function: 'post' });
|
|
34
|
-
});
|
|
35
|
-
it('saves nothing on --dry-run and reports JSON', async () => {
|
|
36
|
-
await learnCommand(cwd, undefined, { before: 'old.ts', after: 'src/http.ts', dryRun: true, json: true });
|
|
37
|
-
expect(fs.existsSync(path.join(cwd, '.rigour'))).toBe(false);
|
|
38
|
-
const report = JSON.parse(String(log.mock.calls[0][0]));
|
|
39
|
-
expect(report.learned).toHaveLength(1);
|
|
40
|
-
expect(report.saved).toEqual([]);
|
|
41
|
-
});
|
|
42
|
-
it('rejects missing or conflicting inputs with a usage exit code', async () => {
|
|
43
|
-
await learnCommand(cwd, undefined, { before: 'old.ts' });
|
|
44
|
-
expect(process.exitCode).toBe(2);
|
|
45
|
-
await learnCommand(cwd, 'abc123', { before: 'old.ts', after: 'src/http.ts' });
|
|
46
|
-
expect(error.mock.calls.flat().join('\n')).toContain('Give only one of');
|
|
47
|
-
await learnCommand(cwd, undefined, { before: 'old.ts', after: 'src/http.ts', maxHits: '-1' });
|
|
48
|
-
expect(error.mock.calls.flat().join('\n')).toContain('--max-hits');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|