@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.
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,41 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buildLearning, readableFixLesson } from './studio-learning.js';
3
- const now = new Date('2026-10-09T12:00:00Z');
4
- const lesson = {
5
- id: 'l1', repositoryId: 'r', visibility: 'team', state: 'validated', kind: 'fix',
6
- subject: 'Fixed before: Credential header follows redirects (semantic-bugs). The cookie reaches fetch.',
7
- evidence: { files: ['src/a.ts', 'src/b.ts'] }, confidence: 1, source: 'fix', createdAt: Date.parse('2026-09-20T00:00:00Z'), updatedAt: 0,
8
- };
9
- const story = (at, stage = 'edit') => ({ id: at, at, stage, file: 'src/c.ts', rule: 'semantic-bugs', title: 'Credential header follows redirects', diff: [] });
10
- describe('buildLearning', () => {
11
- it("follows a lesson from where it was learned to the repeats it stopped", () => {
12
- const events = [
13
- { type: 'lessons_served', timestamp: '2026-10-01T00:00:00Z', via: 'recall', lessons: [lesson.subject] },
14
- { type: 'lessons_served', timestamp: '2026-10-02T00:00:00Z', via: 'review', lessons: [lesson.subject, 'other'] },
15
- ];
16
- const learning = buildLearning({ now, lessons: [lesson], reviewLessons: [], stories: [story('2026-09-19T00:00:00Z', 'stop'), story('2026-10-05T00:00:00Z')], events });
17
- expect(learning.lessons[0]).toMatchObject({
18
- text: 'Credential header follows redirects: The cookie reaches fetch.',
19
- origin: 'development', learnedFrom: '2 fixes as an agent tried to finish', scope: 'team',
20
- told: 2, stoppedInDevelopment: 1, reachedPr: null,
21
- });
22
- expect(learning.prRecorded).toBe(false);
23
- expect(learning.weeks.map(w => [w.stoppedInDevelopment, w.reachedPr])).toEqual([[0, null], [0, null], [0, null], [1, null]]);
24
- });
25
- it('counts repeats that reached a PR once branch reviews are recorded here', () => {
26
- const events = [{ type: 'pr_catches', timestamp: '2026-10-08T00:00:00Z', findings: [{ rule: 'semantic-bugs', title: 'Credential header follows redirects', file: 'src/d.ts' }] }];
27
- const learning = buildLearning({ now, lessons: [lesson], reviewLessons: [], stories: [], events });
28
- expect(learning.lessons[0].reachedPr).toBe(1);
29
- expect(learning.weeks[3].reachedPr).toBe(1);
30
- });
31
- it('does not repeat the title when the detail says the same thing', () => {
32
- const same = { ...lesson, subject: 'Fixed before: Stripe API key detected in code (security-patterns). Stripe API key detected in code.' };
33
- expect(buildLearning({ now, lessons: [same], reviewLessons: [], stories: [], events: [] }).lessons[0].text).toBe('Stripe API key detected in code');
34
- });
35
- it('shows a lesson once, in words, without a truncated title or rule tag', () => {
36
- expect(readableFixLesson("Fixed before: [credential-redirect] The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-h…` (semantic-bugs). [credential-redirect] The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } })` without `redirect: \"manual\"`."))
37
- .toBe("The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } })` without `redirect: \"manual\"`");
38
- const twice = [lesson, { ...lesson, id: 'l2', visibility: 'personal' }];
39
- expect(buildLearning({ now, lessons: twice, reviewLessons: [], stories: [], events: [] }).lessons).toHaveLength(1);
40
- });
41
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,41 +0,0 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { describe, expect, it } from 'vitest';
5
- import { buildProgress, precisionsAcross } from './studio-progress.js';
6
- const now = new Date('2026-10-09T12:00:00Z');
7
- const story = (openedAt, at) => ({ id: at, at, openedAt, stage: 'edit', file: 'a.ts', rule: 'r', title: 't', diff: [] });
8
- const learning = {
9
- prRecorded: false,
10
- weeks: [{ from: '2026-09-25T12:00:00Z', stoppedInDevelopment: 0, reachedPr: null }, { from: '2026-10-02T12:00:00Z', stoppedInDevelopment: 2, reachedPr: null }],
11
- lessons: [
12
- { id: 'a', text: 'Never follow redirects with credentials', origin: 'development', learnedFrom: '', learnedAt: '2026-09-26T00:00:00Z', state: 'validated', scope: 'team', told: 3, stoppedInDevelopment: 2, reachedPr: null, canDecide: false },
13
- { id: 'b', text: 'Amounts are cents', origin: 'development', learnedFrom: '', learnedAt: '2026-10-05T00:00:00Z', state: 'candidate', scope: 'this repo', told: 0, stoppedInDevelopment: 0, reachedPr: null, canDecide: true },
14
- ],
15
- };
16
- describe('buildProgress', () => {
17
- it('reports each week from records: stopped, overruled, median time to fix, lessons so far', () => {
18
- const progress = buildProgress({
19
- now,
20
- stories: [story('2026-10-03T10:00:00Z', '2026-10-03T10:02:00Z'), story('2026-10-04T10:00:00Z', '2026-10-04T10:10:00Z'), story('2026-09-27T09:00:00Z', '2026-09-27T09:04:00Z')],
21
- dismissals: [{ key: 'k', reason: 'x', at: '2026-10-05T00:00:00Z' }],
22
- learning,
23
- checks: [],
24
- });
25
- expect(progress.weeks).toEqual([
26
- { from: '2026-09-25T12:00:00Z', stopped: 1, overruled: 0, repeatsStopped: 0, repeatsReachedPr: null, minutesToFix: 4, lessons: 1 },
27
- { from: '2026-10-02T12:00:00Z', stopped: 2, overruled: 1, repeatsStopped: 2, repeatsReachedPr: null, minutesToFix: 6, lessons: 2 },
28
- ]);
29
- expect(progress.topLessons.map(l => l.id)).toEqual(['a']);
30
- });
31
- it('ranks checks by how often their findings were acted on, summed over every checkout', () => {
32
- const roots = [0, 1].map(() => fs.mkdtempSync(path.join(os.tmpdir(), 'progress-')));
33
- roots.forEach((root, i) => {
34
- fs.mkdirSync(path.join(root, '.rigour'));
35
- fs.writeFileSync(path.join(root, '.rigour', 'check-outcomes.json'), JSON.stringify({ 'security-patterns: Key': { fixed: 2 + i, dismissed: 0 }, 'ast: Too complex': { fixed: 0, dismissed: 3 } }));
36
- });
37
- const checks = buildProgress({ now, stories: [], dismissals: [], learning, checks: precisionsAcross(roots) }).checks;
38
- expect(checks.map(c => [c.check, c.fixed, c.dismissed, c.muted])).toEqual([['security-patterns: Key', 5, 0, false], ['ast: Too complex', 0, 6, true]]);
39
- roots.forEach(root => fs.rmSync(root, { recursive: true, force: true }));
40
- });
41
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,92 +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 { dismissFromStudio, loadStudioReviewer, saveStudioReviewer, saveTeamReviewer } from './studio-reviewer.js';
7
- let repo;
8
- let home;
9
- const savedHome = process.env.RIGOUR_HOME;
10
- beforeEach(() => {
11
- // A home of its own for the person's settings: the shared test home is never written by another test's choice.
12
- home = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-home-reviewer-'));
13
- process.env.RIGOUR_HOME = home;
14
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-reviewer-'));
15
- const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
16
- git('init', '-q', '-b', 'feature');
17
- git('config', 'user.email', 't@example.com');
18
- git('config', 'user.name', 't');
19
- git('commit', '-q', '--allow-empty', '-m', 'init');
20
- });
21
- afterEach(() => {
22
- process.env.RIGOUR_HOME = savedHome;
23
- for (const dir of [repo, home])
24
- fs.rmSync(dir, { recursive: true, force: true });
25
- });
26
- describe('the reviewer in Studio', () => {
27
- it('shows the defaults, where each value comes from, and which agent CLIs exist', async () => {
28
- const view = await loadStudioReviewer(repo);
29
- expect(view).toMatchObject({ branch: 'feature', teamFile: false, user: {}, effective: { enabled: false, mode: 'single', panel: false, source: { mode: 'team', panel: 'team' } } });
30
- expect(view.team).toMatchObject({ mode: 'single', panel: 'off', judges: 2, escalate: 'always' });
31
- expect(view.available.map(a => a.name)).toEqual(['claude', 'cursor', 'codex']);
32
- });
33
- it('saves a person\'s own choice, applies it over the team\'s, and goes back to the team\'s on null', async () => {
34
- const on = await saveStudioReviewer(repo, { enabled: true, panel: true, judges: 3 });
35
- expect(on.user).toEqual({ enabled: true, panel: true, judges: 3 });
36
- expect(on.effective).toMatchObject({ enabled: true, mode: 'full', panel: true, judges: 3, source: { panel: 'user' } });
37
- const back = await saveStudioReviewer(repo, { panel: null });
38
- expect(back.user).toEqual({ enabled: true, judges: 3 });
39
- expect(back.effective.panel).toBe(false);
40
- });
41
- it('refuses what a person cannot set, and the team\'s floor still wins over a person\'s choice', async () => {
42
- await expect(saveStudioReviewer(repo, { mode_required: false })).rejects.toThrow('not a reviewer setting a person can change: mode_required');
43
- await expect(saveStudioReviewer(repo, { judges: 4 })).rejects.toThrow('judges is 2 or 3');
44
- fs.writeFileSync(path.join(repo, 'rigour.yml'), 'version: 1\nreview:\n reviewer:\n mode: full\n panel: required\n');
45
- const view = await saveStudioReviewer(repo, { panel: false });
46
- expect(view.teamFile).toBe(true);
47
- expect(view.effective).toMatchObject({ panel: true, required: { panel: true } });
48
- expect(view.effective.refused).toEqual(['panel off (user) refused: rigour.yml sets review.reviewer.panel: required']);
49
- });
50
- it('dismisses only with a finding id and a reason', async () => {
51
- await expect(dismissFromStudio(repo, { id: 'nope', reason: 'whatever it is' })).rejects.toThrow('reviewer finding id');
52
- await expect(dismissFromStudio(repo, { id: 'abcdef0123', reason: 'no' })).rejects.toThrow('say why');
53
- await expect(dismissFromStudio(repo, { id: 'abcdef0123', reason: 'the lock is one level up' })).rejects.toThrow('this team does not dismiss reviewer findings');
54
- fs.writeFileSync(path.join(repo, 'rigour.yml'), 'version: 1\nreview:\n reviewer:\n dismissals: true\n');
55
- await expect(dismissFromStudio(repo, { id: 'abcdef0123', reason: 'the lock is one level up' })).rejects.toThrow('no open reviewer finding abcdef0123 on feature');
56
- });
57
- it('edits the team\'s rigour.yml in place, comments kept, and shows the diff to commit', async () => {
58
- const yml = '# Team gates\nversion: 1\nreview:\n reviewer:\n # agreed in the retro\n mode: single\n';
59
- fs.writeFileSync(path.join(repo, 'rigour.yml'), yml);
60
- execFileSync('git', ['-C', repo, 'add', 'rigour.yml']);
61
- execFileSync('git', ['-C', repo, 'commit', '-qm', 'team config']);
62
- const view = await saveTeamReviewer(repo, { patch: { mode: 'full', panel: 'required', judges: 3 } });
63
- const after = fs.readFileSync(path.join(repo, 'rigour.yml'), 'utf8');
64
- expect(after).toContain('# Team gates');
65
- expect(after).toContain('# agreed in the retro');
66
- expect(after).toMatch(/mode: full[\s\S]*panel: required[\s\S]*judges: 3/);
67
- expect(view.diff).toContain('- mode: single');
68
- expect(view.diff).toContain('+ panel: required');
69
- expect(view.effective).toMatchObject({ mode: 'full', panel: true, judges: 3, required: { panel: true } });
70
- await saveTeamReviewer(repo, { patch: { judges: null } });
71
- expect(fs.readFileSync(path.join(repo, 'rigour.yml'), 'utf8')).not.toContain('judges');
72
- });
73
- it('refuses a team write that would break rigour.yml, touches other sections, or creates the file unasked', async () => {
74
- await expect(saveTeamReviewer(repo, { patch: { mode: 'full' } })).rejects.toThrow('there is no rigour.yml');
75
- expect(fs.existsSync(path.join(repo, 'rigour.yml'))).toBe(false);
76
- const created = await saveTeamReviewer(repo, { patch: { enabled: true }, create: true });
77
- expect(created.teamFile).toBe(true);
78
- expect(created.diff).toContain('+review:');
79
- expect(created.diff).not.toContain('version');
80
- await expect(saveTeamReviewer(repo, { patch: { gates: {} } })).rejects.toThrow('not a team reviewer setting: gates');
81
- await expect(saveTeamReviewer(repo, { patch: { judges: 7 } })).rejects.toThrow('that would not be a valid rigour.yml');
82
- expect(fs.readFileSync(path.join(repo, 'rigour.yml'), 'utf8')).not.toContain('judges');
83
- });
84
- it('refuses a linked rigour.yml and a model name a CLI would read as a flag', async () => {
85
- const elsewhere = path.join(home, 'elsewhere.yml');
86
- fs.writeFileSync(elsewhere, 'version: 1\n');
87
- fs.symlinkSync(elsewhere, path.join(repo, 'rigour.yml'));
88
- await expect(saveTeamReviewer(repo, { patch: { enabled: true } })).rejects.toThrow('rigour.yml is a link');
89
- expect(fs.readFileSync(elsewhere, 'utf8')).toBe('version: 1\n');
90
- await expect(saveStudioReviewer(repo, { models: { claude: '--dangerously-skip-permissions' } })).rejects.toThrow('not starting with -');
91
- });
92
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,32 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buildWeek } from './studio-week.js';
3
- import { reviewFixStory } from './studio-checkouts.js';
4
- const now = new Date('2026-10-09T12:00:00Z');
5
- const story = (at, stage) => ({ id: at, at, stage, file: 'src/a.ts', rule: 'semantic-bugs', title: 'T', diff: [] });
6
- describe('buildWeek', () => {
7
- it('counts only this week, by the stage that caught it, and says how far the record goes back', () => {
8
- const week = buildWeek({
9
- now,
10
- stories: [story('2026-09-20T00:00:00Z', 'edit'), story('2026-10-05T00:00:00Z', 'edit'), story('2026-10-06T00:00:00Z', 'stop'), story('2026-10-07T00:00:00Z', 'review')],
11
- open: [{ file: 'src/b.ts', rule: 'semantic-bugs', title: 'Open one', openedAt: '2026-10-08T00:00:00Z' }],
12
- dismissals: [{ key: 'k', reason: 'intended', at: '2026-10-08T00:00:00Z' }, { key: 'old', reason: 'x', at: '2026-09-01T00:00:00Z' }],
13
- events: [],
14
- });
15
- expect(week.recordingSince).toBe('2026-09-20T00:00:00Z');
16
- expect(week.stopped).toEqual({ total: 3, byStage: { edit: 1, review: 1, stop: 1, pr: 0 } });
17
- expect(week.agentSaidDone).toBe(1);
18
- expect(week.stories.map(s => s.at)).toEqual(['2026-10-07T00:00:00Z', '2026-10-06T00:00:00Z', '2026-10-05T00:00:00Z']);
19
- expect([week.raised, week.overruled]).toEqual([5, 1]);
20
- expect(week.needs).toEqual([expect.objectContaining({ title: 'Open one' })]);
21
- });
22
- it('reports PR catches as unknown, not zero, when no branch review was ever recorded here', () => {
23
- expect(buildWeek({ now, stories: [], open: [], dismissals: [], events: [] }).prCatches).toBeNull();
24
- const events = [{ type: 'pr_catches', timestamp: '2026-10-08T00:00:00Z', findings: [{ rule: 'r', title: 't', file: 'f' }] }];
25
- expect(buildWeek({ now, stories: [], open: [], dismissals: [], events }).prCatches).toBe(1);
26
- });
27
- it('counts a function an agent fixed in a pre-PR review as a problem stopped', () => {
28
- const fix = reviewFixStory({ file: 'src/insights.ts', function: 'loadDiagnosis', hash: 'h', reviewer: 'agent', verdict: 'fixed', note: 'Six positional parameters replaced by one options object.', at: '2026-10-08T00:00:00Z' });
29
- expect(fix).toMatchObject({ stage: 'review', title: 'Six positional parameters replaced by one options object (in `loadDiagnosis`)', diff: [] });
30
- expect(buildWeek({ now, stories: [fix], open: [], dismissals: [], events: [] }).stopped.byStage.review).toBe(1);
31
- });
32
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,32 +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 { appendTaskEvent } from '@rigour-labs/core';
7
- import { threadCommand } from './thread.js';
8
- let repo;
9
- beforeEach(() => {
10
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'thread-cli-'));
11
- execFileSync('git', ['-C', repo, 'init', '-q', '-b', 'feat/PROJ-9-thing']);
12
- });
13
- afterEach(() => {
14
- vi.restoreAllMocks();
15
- fs.rmSync(repo, { recursive: true, force: true });
16
- });
17
- describe('rigour thread', () => {
18
- it('prints the thread, as text or JSON, and exits 1 with a reason when there is none', () => {
19
- const out = [];
20
- vi.spyOn(console, 'log').mockImplementation((line) => void out.push(String(line)));
21
- vi.spyOn(console, 'error').mockImplementation((line) => void out.push(String(line)));
22
- expect(threadCommand(repo, 'feat/PROJ-9-thing', {})).toBe(1);
23
- expect(out.join('\n')).toContain('no thread for feat/PROJ-9-thing');
24
- appendTaskEvent(repo, { kind: 'push', passed: true, failed: 0 });
25
- out.length = 0;
26
- expect(threadCommand(repo, undefined, {})).toBe(0);
27
- expect(out[0]).toContain('branch:feat/PROJ-9-thing'); // no commit names the ticket yet
28
- out.length = 0;
29
- expect(threadCommand(repo, 'feat/PROJ-9-thing', { json: true })).toBe(0);
30
- expect(JSON.parse(out.join('\n'))).toMatchObject({ task: 'branch:feat/PROJ-9-thing', events: [{ kind: 'push', passed: true }] });
31
- });
32
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,104 +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 { installGitPushHook } from './hooks-git.js';
7
- import { initCommand } from './init.js';
8
- import { uninstall } from './uninstall.js';
9
- let repo;
10
- const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
11
- const read = (rel) => fs.readFileSync(path.join(repo, rel), 'utf8');
12
- const write = (rel, body) => {
13
- fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
14
- fs.writeFileSync(path.join(repo, rel), body);
15
- };
16
- /** Every file in the working tree (and the pre-push hook), with its content: the repository as a person would compare it. */
17
- function snapshot() {
18
- const files = {};
19
- const walk = (dir) => {
20
- for (const entry of fs.readdirSync(path.join(repo, dir), { withFileTypes: true })) {
21
- const rel = path.posix.join(dir, entry.name);
22
- if (rel === '.git' || rel.startsWith('.rigour'))
23
- continue;
24
- if (entry.isDirectory())
25
- walk(rel);
26
- else
27
- files[rel] = read(rel);
28
- }
29
- };
30
- walk('.');
31
- const hook = path.join(repo, '.git/hooks/pre-push');
32
- if (fs.existsSync(hook))
33
- files['.git/hooks/pre-push'] = fs.readFileSync(hook, 'utf8');
34
- return files;
35
- }
36
- const OWN_SETTINGS = JSON.stringify({ permissions: { allow: ['Bash(ls)'] }, env: { FOO: '1' }, hooks: { PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'npm run format' }] }] } }, null, 2);
37
- const OWN_MCP = JSON.stringify({ mcpServers: { docs: { command: 'docs-server' } } }, null, 2);
38
- beforeEach(() => {
39
- repo = fs.mkdtempSync(path.join(os.tmpdir(), 'uninstall-'));
40
- git('init', '-q', '-b', 'main');
41
- git('config', 'core.hooksPath', '.git/hooks');
42
- write('package.json', JSON.stringify({ name: 'app' }));
43
- write('.gitignore', 'node_modules\n');
44
- write('AGENTS.md', '# Our own agent rules\n');
45
- write('.claude/settings.json', OWN_SETTINGS);
46
- write('.mcp.json', OWN_MCP);
47
- vi.spyOn(console, 'log').mockImplementation(() => undefined);
48
- vi.spyOn(console, 'error').mockImplementation(() => undefined);
49
- vi.spyOn(console, 'warn').mockImplementation(() => undefined);
50
- });
51
- afterEach(() => {
52
- vi.restoreAllMocks();
53
- fs.rmSync(repo, { recursive: true, force: true });
54
- });
55
- describe('rigour uninstall', () => {
56
- it('after init, takes out exactly what Rigour put in: the repository is as it was, rigour.yml and .rigour/ aside', async () => {
57
- const before = snapshot();
58
- await initCommand(repo, { instructions: true });
59
- // What init did to the person's own files: merged, never replaced.
60
- const settings = JSON.parse(read('.claude/settings.json'));
61
- expect(settings.permissions).toEqual({ allow: ['Bash(ls)'] });
62
- expect(settings.env).toEqual({ FOO: '1' });
63
- expect(settings.mcpServers).toBeUndefined(); // Claude Code reads MCP servers from .mcp.json
64
- expect(JSON.stringify(settings.hooks)).toContain('npm run format');
65
- expect(JSON.stringify(settings.hooks)).toMatch(/hooks check/);
66
- expect(Object.keys(JSON.parse(read('.mcp.json')).mcpServers)).toEqual(['docs', 'rigour']);
67
- expect(read('AGENTS.md')).toBe('# Our own agent rules\n'); // the team's own instructions are kept
68
- expect(fs.existsSync(path.join(repo, 'CLAUDE.md'))).toBe(true);
69
- expect(read('.git/hooks/pre-push')).toContain('hooks push --git');
70
- const dry = uninstall(repo, { dryRun: true });
71
- expect(dry.changes.length).toBeGreaterThan(3); // Claude Code's hooks, .mcp.json, CLAUDE.md, the git hook
72
- expect(snapshot()['.claude/settings.json']).toBe(read('.claude/settings.json')); // a dry run changes nothing
73
- uninstall(repo);
74
- const after = snapshot();
75
- const expected = { ...before, 'rigour.yml': after['rigour.yml'], '.gitignore': after['.gitignore'] };
76
- for (const rel of Object.keys(expected))
77
- if (rel.endsWith('.json'))
78
- expected[rel] = JSON.stringify(JSON.parse(expected[rel]), null, 4) + '\n';
79
- expect(Object.keys(after).sort()).toEqual(Object.keys(expected).sort());
80
- expect(JSON.parse(after['.claude/settings.json'])).toEqual(JSON.parse(OWN_SETTINGS));
81
- expect(JSON.parse(after['.mcp.json'])).toEqual(JSON.parse(OWN_MCP));
82
- expect(after['AGENTS.md']).toBe('# Our own agent rules\n');
83
- // --all: rigour.yml, .rigour/ and the .gitignore lines go too.
84
- uninstall(repo, { all: true });
85
- expect(fs.existsSync(path.join(repo, 'rigour.yml'))).toBe(false);
86
- expect(fs.existsSync(path.join(repo, '.rigour'))).toBe(false);
87
- expect(read('.gitignore')).toBe('node_modules\n');
88
- });
89
- it('keeps a file Rigour created that the person has since edited, and says so', async () => {
90
- await initCommand(repo, { instructions: true });
91
- fs.appendFileSync(path.join(repo, 'CLAUDE.md'), '\n## Our addition\n');
92
- const report = uninstall(repo);
93
- expect(fs.existsSync(path.join(repo, 'CLAUDE.md'))).toBe(true);
94
- expect(report.kept).toContainEqual('CLAUDE.md: edited since Rigour created it');
95
- });
96
- it('removes only its own lines from a pre-push hook another tool owns', () => {
97
- write('.husky/pre-push', '#!/bin/sh\nnpm test\n');
98
- git('config', 'core.hooksPath', '.husky');
99
- installGitPushHook(repo, 'rigour');
100
- expect(read('.husky/pre-push')).toContain('hooks push --git');
101
- uninstall(repo);
102
- expect(read('.husky/pre-push')).toBe('#!/bin/sh\nnpm test\n');
103
- });
104
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,55 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import fs from 'fs-extra';
3
- import path from 'path';
4
- import os from 'os';
5
- import { initCommand } from './commands/init.js';
6
- describe('rigour init: agents and instruction files', () => {
7
- let testDir;
8
- beforeEach(() => {
9
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-init-rules-'));
10
- vi.spyOn(console, 'log').mockImplementation(() => { });
11
- });
12
- afterEach(async () => {
13
- vi.restoreAllMocks();
14
- await fs.remove(testDir);
15
- });
16
- it('writes one AGENTS.md and a CLAUDE.md that imports it, and nothing per agent', async () => {
17
- await initCommand(testDir, { instructions: true });
18
- expect(await fs.readFile(path.join(testDir, 'AGENTS.md'), 'utf-8')).toContain('rigour review --base origin/main');
19
- expect(await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8')).toBe('@AGENTS.md\n');
20
- for (const rel of ['docs/AGENT_INSTRUCTIONS.md', '.cursor/rules/rigour.mdc', '.clinerules/rigour.md', '.windsurfrules', '.gemini/styleguide.md']) {
21
- expect(await fs.pathExists(path.join(testDir, rel)), rel).toBe(false);
22
- }
23
- });
24
- it("keeps the team's own instruction files and says what to add", async () => {
25
- await fs.writeFile(path.join(testDir, 'AGENTS.md'), '# Ours\n');
26
- await fs.writeFile(path.join(testDir, 'CLAUDE.md'), '# Ours too\n');
27
- await initCommand(testDir, { instructions: true });
28
- expect(await fs.readFile(path.join(testDir, 'AGENTS.md'), 'utf-8')).toBe('# Ours\n');
29
- expect(await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8')).toBe('# Ours too\n');
30
- const said = vi.mocked(console.log).mock.calls.flat().join('\n');
31
- expect(said).toContain("Kept your AGENTS.md: add Rigour's section");
32
- expect(said).toContain('add a line "@AGENTS.md"');
33
- });
34
- it('sets up only the agents the repository uses: Claude Code when it shows none', async () => {
35
- await initCommand(testDir);
36
- expect(await fs.pathExists(path.join(testDir, '.claude', 'settings.json'))).toBe(true);
37
- for (const rel of ['.cursor', '.clinerules', '.windsurf'])
38
- expect(await fs.pathExists(path.join(testDir, rel)), rel).toBe(false);
39
- });
40
- it('sets up an agent the repository shows signs of, or the ones asked for', async () => {
41
- await fs.ensureDir(path.join(testDir, '.cursor'));
42
- await initCommand(testDir);
43
- expect(await fs.pathExists(path.join(testDir, '.cursor', 'hooks.json'))).toBe(true);
44
- expect(await fs.pathExists(path.join(testDir, '.windsurf'))).toBe(false);
45
- const other = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-init-rules-'));
46
- try {
47
- await initCommand(other, { ide: 'windsurf' });
48
- expect(await fs.pathExists(path.join(other, '.windsurf', 'hooks.json'))).toBe(true);
49
- expect(await fs.pathExists(path.join(other, '.claude', 'settings.json'))).toBe(false);
50
- }
51
- finally {
52
- await fs.remove(other);
53
- }
54
- });
55
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,82 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import fs from 'fs-extra';
3
- import path from 'path';
4
- import os from 'os';
5
- async function getCheckCommand() {
6
- const { checkCommand } = await import('./commands/check.js');
7
- return checkCommand;
8
- }
9
- describe('CLI Smoke Test', () => {
10
- const testDir = path.join(os.tmpdir(), 'rigour-temp-smoke-test-' + process.pid);
11
- beforeEach(async () => {
12
- await fs.ensureDir(testDir);
13
- // @ts-ignore
14
- vi.spyOn(process, 'exit').mockImplementation(() => { });
15
- });
16
- afterEach(async () => {
17
- try {
18
- const restrictedDir = path.join(testDir, '.restricted');
19
- if (await fs.pathExists(restrictedDir)) {
20
- await fs.chmod(restrictedDir, 0o777);
21
- }
22
- await fs.remove(testDir);
23
- }
24
- catch (e) {
25
- // Best effort cleanup
26
- }
27
- vi.restoreAllMocks();
28
- });
29
- it('should respect ignore patterns and avoid EPERM', async () => {
30
- const restrictedDir = path.join(testDir, '.restricted');
31
- await fs.ensureDir(restrictedDir);
32
- await fs.writeFile(path.join(restrictedDir, 'secret.js'), 'TODO: leak');
33
- await fs.writeFile(path.join(testDir, 'rigour.yml'), `
34
- version: 1
35
- ignore:
36
- - ".restricted/**"
37
- gates:
38
- forbid_todos: true
39
- required_files: []
40
- context:
41
- enabled: false
42
- environment:
43
- enabled: false
44
- safety:
45
- protected_paths: []
46
- retry_loop_breaker:
47
- enabled: false
48
- `);
49
- // Simulate EPERM by changing permissions
50
- await fs.chmod(restrictedDir, 0o000);
51
- try {
52
- // We need to mock process.exit or checkCommand should not exit if we want to test it easily
53
- // For now, we'll just verify it doesn't throw before it would exit (internal logic)
54
- // But checkCommand calls process.exit(1) on failure.
55
- // Re-importing checkCommand to ensure it uses the latest core
56
- const checkCommand = await getCheckCommand();
57
- await expect(checkCommand(testDir, [], { ci: true })).resolves.not.toThrow();
58
- }
59
- finally {
60
- await fs.chmod(restrictedDir, 0o777);
61
- }
62
- }, 30_000);
63
- it('should check specific files when provided', async () => {
64
- await fs.writeFile(path.join(testDir, 'bad.js'), '// TODO: fixme');
65
- await fs.writeFile(path.join(testDir, 'good.js'), 'console.log("hello")');
66
- await fs.writeFile(path.join(testDir, 'rigour.yml'), `
67
- version: 1
68
- gates:
69
- forbid_todos: true
70
- required_files: []
71
- `);
72
- // If we check ONLY good.js, it should PASS (exit PASS)
73
- const checkCommand = await getCheckCommand();
74
- await checkCommand(testDir, [path.join(testDir, 'good.js')], { ci: true });
75
- expect(process.exit).toHaveBeenCalledWith(0);
76
- // If we check bad.js, it should FAIL (exit FAIL)
77
- vi.clearAllMocks();
78
- const checkCommandFail = await getCheckCommand();
79
- await checkCommandFail(testDir, [path.join(testDir, 'bad.js')], { ci: true });
80
- expect(process.exit).toHaveBeenCalledWith(1);
81
- });
82
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,9 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { getCliVersion } from './cli-version.js';
3
- describe('getCliVersion', () => {
4
- it('resolves package version at runtime', () => {
5
- const version = getCliVersion();
6
- expect(version).toMatch(/^\d+\.\d+\.\d+/);
7
- expect(version).not.toBe('0.0.0');
8
- });
9
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,22 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { checkForUpdates } from './version.js';
3
- describe('the update check', () => {
4
- afterEach(() => { vi.unstubAllGlobals(); });
5
- it('never asks the registry in CI, in an agent hook, or when the person opted out', async () => {
6
- const fetch = vi.fn();
7
- vi.stubGlobal('fetch', fetch);
8
- const review = ['node', 'rigour', 'review'];
9
- for (const env of [{ RIGOUR_UPDATE_CHECK: '0' }, { DO_NOT_TRACK: '1' }, { DO_NOT_TRACK: 'yes' }, { CI: 'true' }, { GITHUB_ACTIONS: 'true' }]) {
10
- expect(await checkForUpdates('1.0.0', env, review)).toBeNull();
11
- }
12
- expect(await checkForUpdates('1.0.0', {}, ['node', 'rigour', 'hooks', 'check'])).toBeNull(); // an agent hook on every edit
13
- expect(fetch).not.toHaveBeenCalled();
14
- });
15
- it('asks once a day for a person at a terminal', async () => {
16
- const fetch = vi.fn(async () => new Response(JSON.stringify({ version: '9.0.0' })));
17
- vi.stubGlobal('fetch', fetch);
18
- expect(await checkForUpdates('1.0.0', {}, ['node', 'rigour', 'review'])).toMatchObject({ hasUpdate: true, latestVersion: '9.0.0' });
19
- expect(await checkForUpdates('1.0.0', {}, ['node', 'rigour', 'review'])).toMatchObject({ latestVersion: '9.0.0' });
20
- expect(fetch).toHaveBeenCalledTimes(1);
21
- });
22
- });