@rigour-labs/cli 6.7.10 → 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.
- package/dist/cli.js +43 -0
- package/dist/commands/brief.d.ts +29 -0
- package/dist/commands/brief.js +169 -0
- package/dist/commands/hooks-push.js +2 -1
- package/dist/commands/hooks-stop.js +2 -1
- package/dist/commands/hooks.d.ts +4 -0
- package/dist/commands/hooks.js +32 -7
- package/dist/commands/thread.d.ts +3 -0
- package/dist/commands/thread.js +26 -0
- 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/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 -86
- package/dist/commands/hooks-stop.test.d.ts +0 -1
- package/dist/commands/hooks-stop.test.js +0 -165
- package/dist/commands/hooks.test.d.ts +0 -1
- package/dist/commands/hooks.test.js +0 -247
- 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/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,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the demo command — all modes: default, --hooks, --cinematic.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import { demoCommand } from './demo.js';
|
|
6
|
-
describe('demoCommand', () => {
|
|
7
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
let consoleSpy;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
11
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
12
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
13
|
-
});
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.restoreAllMocks();
|
|
16
|
-
});
|
|
17
|
-
it('should run default demo without errors', async () => {
|
|
18
|
-
await expect(demoCommand({})).resolves.not.toThrow();
|
|
19
|
-
}, 30_000);
|
|
20
|
-
it('should run with --hooks flag', async () => {
|
|
21
|
-
await expect(demoCommand({ hooks: true, speed: 'fast' })).resolves.not.toThrow();
|
|
22
|
-
// Should show hooks simulation
|
|
23
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
24
|
-
expect(allOutput).toContain('hooks');
|
|
25
|
-
}, 30_000);
|
|
26
|
-
it('should run with --cinematic flag at fast speed', async () => {
|
|
27
|
-
await expect(demoCommand({ cinematic: true, speed: 'fast' })).resolves.not.toThrow();
|
|
28
|
-
// Cinematic mode should show the before/after flow
|
|
29
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
30
|
-
expect(allOutput).toContain('fix');
|
|
31
|
-
}, 60_000);
|
|
32
|
-
it('should produce FAIL status on demo project', async () => {
|
|
33
|
-
await demoCommand({ speed: 'fast' });
|
|
34
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
35
|
-
expect(allOutput).toContain('FAIL');
|
|
36
|
-
}, 30_000);
|
|
37
|
-
it('should generate fix packet and audit report', async () => {
|
|
38
|
-
await demoCommand({ speed: 'fast' });
|
|
39
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
40
|
-
expect(allOutput).toContain('Fix packet generated');
|
|
41
|
-
expect(allOutput).toContain('Audit report exported');
|
|
42
|
-
}, 30_000);
|
|
43
|
-
it('should show score bars in output', async () => {
|
|
44
|
-
await demoCommand({ speed: 'fast' });
|
|
45
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
46
|
-
// Score bars use block characters
|
|
47
|
-
expect(allOutput).toContain('/100');
|
|
48
|
-
}, 30_000);
|
|
49
|
-
it('should show whitepaper link in cinematic closing', async () => {
|
|
50
|
-
await demoCommand({ cinematic: true, speed: 'fast' });
|
|
51
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
52
|
-
expect(allOutput).toContain('zenodo.org');
|
|
53
|
-
}, 60_000);
|
|
54
|
-
it('should show hooks init command in closing', async () => {
|
|
55
|
-
await demoCommand({ speed: 'fast' });
|
|
56
|
-
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
57
|
-
expect(allOutput).toContain('hooks init');
|
|
58
|
-
}, 30_000);
|
|
59
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { detectInstallKind, hasVersionShadowing } from './doctor.js';
|
|
3
|
-
describe('doctor helpers', () => {
|
|
4
|
-
it('classifies Homebrew paths', () => {
|
|
5
|
-
expect(detectInstallKind('/opt/homebrew/bin/rigour')).toBe('homebrew');
|
|
6
|
-
expect(detectInstallKind('/usr/local/bin/rigour')).toBe('homebrew');
|
|
7
|
-
expect(detectInstallKind('/opt/homebrew/Cellar/rigour/4.0.5/bin/rigour')).toBe('homebrew');
|
|
8
|
-
});
|
|
9
|
-
it('classifies npm/global node_modules paths', () => {
|
|
10
|
-
expect(detectInstallKind('/opt/homebrew/lib/node_modules/@rigour-labs/cli/dist/cli.js')).toBe('npm');
|
|
11
|
-
expect(detectInstallKind('/Users/test/.nvm/versions/node/v22.0.0/lib/node_modules/@rigour-labs/cli/dist/cli.js')).toBe('npm');
|
|
12
|
-
});
|
|
13
|
-
it('returns unknown for unrelated paths', () => {
|
|
14
|
-
expect(detectInstallKind('/usr/bin/rigour')).toBe('unknown');
|
|
15
|
-
});
|
|
16
|
-
it('detects version shadowing only when versions differ', () => {
|
|
17
|
-
expect(hasVersionShadowing(['4.0.5', '4.0.5'])).toBe(false);
|
|
18
|
-
expect(hasVersionShadowing(['4.0.5', '2.0.0'])).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { exitCodeFor, EXIT_FAIL, EXIT_INTERNAL_ERROR, EXIT_PASS } from './exit-codes.js';
|
|
3
|
-
const report = (status, deepStatus) => ({
|
|
4
|
-
status,
|
|
5
|
-
stats: { duration_ms: 1, ...(deepStatus ? { deep: { enabled: true, status: deepStatus } } : {}) },
|
|
6
|
-
});
|
|
7
|
-
describe('exitCodeFor', () => {
|
|
8
|
-
it('maps PASS and FAIL to 0 and 1', () => {
|
|
9
|
-
expect(exitCodeFor(report('PASS'))).toBe(EXIT_PASS);
|
|
10
|
-
expect(exitCodeFor(report('FAIL'))).toBe(EXIT_FAIL);
|
|
11
|
-
});
|
|
12
|
-
it('exits 3 when deep analysis was requested but did not run', () => {
|
|
13
|
-
expect(exitCodeFor(report('FAIL', 'error'))).toBe(EXIT_INTERNAL_ERROR);
|
|
14
|
-
expect(exitCodeFor(report('PASS', 'error'))).toBe(EXIT_INTERNAL_ERROR);
|
|
15
|
-
});
|
|
16
|
-
it('keeps the normal code for completed or partial deep runs', () => {
|
|
17
|
-
expect(exitCodeFor(report('PASS', 'ok'))).toBe(EXIT_PASS);
|
|
18
|
-
expect(exitCodeFor(report('FAIL', 'partial'))).toBe(EXIT_FAIL);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,25 +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, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { exportTrainingSitesCommand } from './export-training-sites.js';
|
|
7
|
-
describe('rigour export-training-sites', () => {
|
|
8
|
-
let repo;
|
|
9
|
-
afterEach(() => { if (repo)
|
|
10
|
-
fs.rmSync(repo, { recursive: true, force: true }); repo = undefined; vi.restoreAllMocks(); });
|
|
11
|
-
it('writes one JSON line per awaited call in tracked source files, skipping tests', () => {
|
|
12
|
-
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'sites-cli-'));
|
|
13
|
-
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
14
|
-
fs.mkdirSync(path.join(repo, 'src'));
|
|
15
|
-
fs.writeFileSync(path.join(repo, 'src/load.ts'), 'declare function get(): Promise<number>;\nexport async function load() { return await get(); }\n');
|
|
16
|
-
fs.writeFileSync(path.join(repo, 'src/load.test.ts'), 'declare function get(): Promise<number>;\nexport async function t() { return await get(); }\n');
|
|
17
|
-
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
18
|
-
const out = [];
|
|
19
|
-
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out.push(String(chunk)); return true; });
|
|
20
|
-
exportTrainingSitesCommand(repo, []);
|
|
21
|
-
const sites = out.join('').trim().split('\n').map(line => JSON.parse(line));
|
|
22
|
-
expect(sites).toEqual([expect.objectContaining({ file: 'src/load.ts', function: 'load', callee: 'get', ordinal: 0, handledBy: null, line: 2 })]);
|
|
23
|
-
expect(sites[0].source).toContain('return await get()');
|
|
24
|
-
});
|
|
25
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,33 +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 { groupFilesByRepo, hookFindings } from './hooks-check-repos.js';
|
|
7
|
-
let base;
|
|
8
|
-
const repo = (name) => {
|
|
9
|
-
const dir = path.join(base, name);
|
|
10
|
-
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
11
|
-
execFileSync('git', ['init', '-q', dir]);
|
|
12
|
-
return fs.realpathSync.native(dir);
|
|
13
|
-
};
|
|
14
|
-
beforeEach(() => { base = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-repos-')); });
|
|
15
|
-
afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
16
|
-
describe('groupFilesByRepo', () => {
|
|
17
|
-
it("checks each file in its own repository, not the session's", () => {
|
|
18
|
-
const app = repo('app');
|
|
19
|
-
const lib = repo('lib');
|
|
20
|
-
const groups = groupFilesByRepo(app, ['src/a.ts', path.join(lib, 'src/b.ts')]);
|
|
21
|
-
expect(groups).toEqual([{ root: app, files: ['src/a.ts'] }, { root: lib, files: ['src/b.ts'] }]);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe('hookFindings', () => {
|
|
25
|
-
it('keeps code findings and drops findings about the write itself', () => {
|
|
26
|
-
const result = { status: 'fail', duration_ms: 1, failures: [
|
|
27
|
-
{ gate: 'security-patterns', file: 'src/a.ts', message: 'Hard-coded secret', severity: 'critical', line: 3 },
|
|
28
|
-
{ gate: 'file-guard', file: '.rigour/dismissed.json', message: 'BLOCKED', severity: 'critical' },
|
|
29
|
-
{ gate: 'file-size', file: 'src/big.ts', message: 'Too long', severity: 'low' },
|
|
30
|
-
] };
|
|
31
|
-
expect(hookFindings(result)).toEqual([expect.objectContaining({ id: 'security-patterns', title: 'Hard-coded secret', files: ['src/a.ts'], line: 3 })]);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,64 +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 { fileURLToPath } from 'url';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
-
import { gitPushGateCommand, installGitPushHook, selfTestCommand, selfTestGitPushHook } from './hooks-git.js';
|
|
8
|
-
let repo;
|
|
9
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
10
|
-
const bin = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../dist/bin.js');
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-'));
|
|
13
|
-
git('init', '-q', '-b', 'main');
|
|
14
|
-
git('config', 'user.email', 't@example.com');
|
|
15
|
-
git('config', 'user.name', 't');
|
|
16
|
-
git('config', 'commit.gpgsign', 'false');
|
|
17
|
-
git('config', 'core.hooksPath', '.git/hooks'); // a machine-wide hooks path must not reach the test
|
|
18
|
-
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
|
19
|
-
fs.writeFileSync(path.join(repo, 'rigour.yml'), 'version: 1\ngates:\n unused_exports:\n block: true\n'); // this team blocks on dead code
|
|
20
|
-
git('add', '-A');
|
|
21
|
-
git('commit', '-qm', 'init');
|
|
22
|
-
});
|
|
23
|
-
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
24
|
-
describe('the git pre-push hook', () => {
|
|
25
|
-
it('is installed where git looks, once, and completes a hook another tool owns instead of replacing it', () => {
|
|
26
|
-
const first = installGitPushHook(repo, 'npx --yes @rigour-labs/cli@9.9.9');
|
|
27
|
-
expect(first.action).toBe('installed');
|
|
28
|
-
const text = fs.readFileSync(first.path, 'utf8');
|
|
29
|
-
expect(text).toContain('exec npx --yes @rigour-labs/cli@9.9.9 hooks push --git "$@"');
|
|
30
|
-
if (process.platform !== 'win32')
|
|
31
|
-
expect(fs.statSync(first.path).mode & 0o111).toBeTruthy(); // no execute bit on Windows
|
|
32
|
-
expect(installGitPushHook(repo, 'npx --yes @rigour-labs/cli@9.9.9').action).toBe('present');
|
|
33
|
-
git('config', 'core.hooksPath', '.husky');
|
|
34
|
-
fs.mkdirSync(path.join(repo, '.husky'));
|
|
35
|
-
fs.writeFileSync(path.join(repo, '.husky/pre-push'), '#!/bin/sh\nnpm test\n');
|
|
36
|
-
const appended = installGitPushHook(repo, 'rigour');
|
|
37
|
-
expect(appended).toMatchObject({ action: 'appended', path: path.join(repo, '.husky/pre-push') });
|
|
38
|
-
expect(fs.readFileSync(appended.path, 'utf8')).toBe('#!/bin/sh\nnpm test\n\n# Rigour push gate (rigour hooks init): the same gate for every tool and the terminal.\nrigour hooks push --git "$@" || exit $?\n');
|
|
39
|
-
expect(installGitPushHook(repo, 'rigour').action).toBe('present');
|
|
40
|
-
expect(installGitPushHook(path.join(os.tmpdir()), 'rigour').action).toBe('no repository');
|
|
41
|
-
// A hooks directory outside the repository belongs to whoever set it: named, never written.
|
|
42
|
-
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'global-hooks-'));
|
|
43
|
-
fs.writeFileSync(path.join(elsewhere, 'pre-push'), '#!/bin/sh\nexit 0\n');
|
|
44
|
-
git('config', 'core.hooksPath', elsewhere);
|
|
45
|
-
expect(installGitPushHook(repo, 'rigour')).toEqual({ path: path.join(elsewhere, 'pre-push'), action: 'managed elsewhere' });
|
|
46
|
-
expect(fs.readFileSync(path.join(elsewhere, 'pre-push'), 'utf8')).toBe('#!/bin/sh\nexit 0\n');
|
|
47
|
-
fs.rmSync(elsewhere, { recursive: true, force: true });
|
|
48
|
-
});
|
|
49
|
-
it('refuses a push from a tree with uncommitted changes to tracked files, and lets a branch deletion through', async () => {
|
|
50
|
-
const refs = `refs/heads/main ${git('rev-parse', 'HEAD')} refs/heads/main 0000000000000000000000000000000000000000\n`;
|
|
51
|
-
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 2;\n');
|
|
52
|
-
const dirty = await gitPushGateCommand(refs, repo);
|
|
53
|
-
expect(dirty.exitCode).toBe(1);
|
|
54
|
-
expect(dirty.message).toContain('uncommitted changes to tracked files');
|
|
55
|
-
expect(dirty.message).toContain(' M a.ts');
|
|
56
|
-
const deletion = await gitPushGateCommand('(delete) 0000000000000000000000000000000000000000 refs/heads/old 1234567890123456789012345678901234567890\n', repo);
|
|
57
|
-
expect(deletion).toEqual({ exitCode: 0, message: '' });
|
|
58
|
-
});
|
|
59
|
-
it('holds under a real git push: a change the gate must refuse is refused, the fix lands, read from the remote', async () => {
|
|
60
|
-
const result = await selfTestGitPushHook(selfTestCommand(bin));
|
|
61
|
-
expect(result.steps.join('\n')).toContain('was refused');
|
|
62
|
-
expect(result).toMatchObject({ ok: true });
|
|
63
|
-
}, 120_000);
|
|
64
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,86 +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 { hooksPushCommand, isPush, pushTarget } from './hooks-push.js';
|
|
7
|
-
describe('push command parsing', () => {
|
|
8
|
-
it('acts on a real push only', () => {
|
|
9
|
-
expect(isPush('git push -u origin feature')).toBe(true);
|
|
10
|
-
expect(isPush('cd app && git -C . push')).toBe(true);
|
|
11
|
-
expect(isPush('git push --dry-run')).toBe(false);
|
|
12
|
-
expect(isPush('git status && npm test')).toBe(false);
|
|
13
|
-
expect(isPush('echo "do not git pushx"')).toBe(false);
|
|
14
|
-
expect(isPush('claude -p review --disallowedTools "Bash(git push:*)"')).toBe(false); // text inside an argument
|
|
15
|
-
expect(isPush('npm test && git push origin main')).toBe(true);
|
|
16
|
-
expect(isPush('git -C "/work/my app" push')).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
it('finds the repository the push runs in', () => {
|
|
19
|
-
expect(pushTarget('cd /work/app && git push')).toBe('/work/app');
|
|
20
|
-
expect(pushTarget('git -C "/work/my app" push')).toBe('/work/my app');
|
|
21
|
-
expect(pushTarget('git push')).toBeUndefined();
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe('rigour hooks push', () => {
|
|
25
|
-
let repo;
|
|
26
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
27
|
-
const write = (rel, body) => {
|
|
28
|
-
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
29
|
-
fs.writeFileSync(path.join(repo, rel), body);
|
|
30
|
-
};
|
|
31
|
-
const push = (command = 'git push') => hooksPushCommand(JSON.stringify({ cwd: repo, tool_input: { command } }), '/');
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'push-gate-'));
|
|
34
|
-
git('init', '-q', '-b', 'main');
|
|
35
|
-
git('config', 'user.email', 't@example.com');
|
|
36
|
-
git('config', 'user.name', 't');
|
|
37
|
-
git('config', 'commit.gpgsign', 'false');
|
|
38
|
-
write('.gitignore', '.rigour/\nnode_modules/\n');
|
|
39
|
-
write('src/main.ts', "import { used } from './util';\nconsole.log(used);\n");
|
|
40
|
-
write('src/util.ts', 'export const used = 1;\n');
|
|
41
|
-
write('package.json', '{"scripts":{"start":"node src/main.ts"}}\n');
|
|
42
|
-
write('rigour.yml', 'version: 1\ngates:\n unused_exports:\n block: true\n'); // this team blocks on dead code
|
|
43
|
-
git('add', '-A');
|
|
44
|
-
git('commit', '-qm', 'init');
|
|
45
|
-
git('checkout', '-q', '-b', 'feature');
|
|
46
|
-
});
|
|
47
|
-
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
48
|
-
it('lets any other command, and a clean branch, through', async () => {
|
|
49
|
-
expect(await push('npm test')).toEqual({ exitCode: 0, message: '' });
|
|
50
|
-
write('src/util.ts', 'export const used = 2;\n');
|
|
51
|
-
git('commit', '-qam', 'tweak');
|
|
52
|
-
expect(await push()).toEqual({ exitCode: 0, message: '' });
|
|
53
|
-
});
|
|
54
|
-
it('blocks a push when a check could not run: a TypeScript project whose program cannot be built', async () => {
|
|
55
|
-
write('tsconfig.json', '{"extends":"./.generated/tsconfig.json"}\n');
|
|
56
|
-
write('src/util.ts', 'export const used = 2;\n');
|
|
57
|
-
git('add', '-A');
|
|
58
|
-
git('commit', '-qm', 'typed change, broken config');
|
|
59
|
-
const result = await push();
|
|
60
|
-
expect(result.exitCode).toBe(2);
|
|
61
|
-
expect(result.message).toContain('- typed-checks-unavailable could not run: ');
|
|
62
|
-
expect(result.message).toContain('.generated/tsconfig.json');
|
|
63
|
-
});
|
|
64
|
-
it('blocks a push that adds an export nothing uses, saying where', async () => {
|
|
65
|
-
write('src/util.ts', 'export const used = 1;\nexport const forgotten = 2;\n');
|
|
66
|
-
git('commit', '-qam', 'add forgotten');
|
|
67
|
-
const result = await push();
|
|
68
|
-
expect(result.exitCode).toBe(2);
|
|
69
|
-
expect(result.message).toContain('Push blocked');
|
|
70
|
-
expect(result.message).toContain('src/util.ts:2 Unused export');
|
|
71
|
-
});
|
|
72
|
-
it("blocks a push the project's own formatter rejects, and keeps the output in a log", async () => {
|
|
73
|
-
const bin = path.join(repo, 'node_modules', '.bin');
|
|
74
|
-
fs.mkdirSync(bin, { recursive: true });
|
|
75
|
-
fs.writeFileSync(path.join(bin, 'prettier.js'), "console.log('[warn] src/util.ts'); process.exit(1);\n");
|
|
76
|
-
fs.writeFileSync(path.join(bin, 'prettier'), '#!/bin/sh\nexec node "$(dirname "$0")/prettier.js" "$@"\n', { mode: 0o755 });
|
|
77
|
-
fs.writeFileSync(path.join(bin, 'prettier.cmd'), '@node "%~dp0\\prettier.js" %*\r\n');
|
|
78
|
-
write('src/util.ts', 'export const used = 3;\n');
|
|
79
|
-
git('commit', '-qam', 'unformatted');
|
|
80
|
-
const result = await push();
|
|
81
|
-
expect(result.exitCode).toBe(2);
|
|
82
|
-
expect(result.message).toContain('- format failed: prettier --check');
|
|
83
|
-
const log = result.message.match(/full output: (\S+)\)/)[1];
|
|
84
|
-
expect(fs.readFileSync(log, 'utf8')).toContain('[warn] src/util.ts');
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,165 +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 { 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
|
-
for (const n of [1, 2]) {
|
|
39
|
-
write('src/notify.ts', `${LEAKY}// attempt ${n}\n`); // the agent edits, still leaking
|
|
40
|
-
await hooksStopCommand('claude', payload, '/');
|
|
41
|
-
}
|
|
42
|
-
write('src/notify.ts', `${LEAKY}// attempt 3\n`);
|
|
43
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
44
|
-
});
|
|
45
|
-
it('sends Cursor a follow-up message, and stops following up at the loop limit', async () => {
|
|
46
|
-
write('src/notify.ts', LEAKY);
|
|
47
|
-
const reply = JSON.parse(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'completed', loop_count: 0 }), '/'));
|
|
48
|
-
expect(reply.followup_message).toContain('src/notify.ts:2');
|
|
49
|
-
expect(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'completed', loop_count: 3 }), '/')).toBe('');
|
|
50
|
-
expect(await hooksStopCommand('cursor', JSON.stringify({ cwd: repo, status: 'aborted' }), '/')).toBe('');
|
|
51
|
-
});
|
|
52
|
-
it('asks once, at the end of the task, about what the team learned that applies to the change', async () => {
|
|
53
|
-
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' };
|
|
54
|
-
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'] }] }));
|
|
55
|
-
write('src/load.ts', HEURISTIC);
|
|
56
|
-
write('src/main.ts', "import { load } from './load';\nvoid load('/');\n");
|
|
57
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'teach' });
|
|
58
|
-
const first = JSON.parse(await hooksStopCommand('claude', payload, '/'));
|
|
59
|
-
expect(first.decision).toBe('block');
|
|
60
|
-
expect(first.reason).toContain('src/load.ts: Time out every fetch the loader makes. (acted on in PR #7, #9)');
|
|
61
|
-
expect(first.reason).not.toContain('billing');
|
|
62
|
-
write('src/load.ts', `${HEURISTIC}// the agent checked it\n`);
|
|
63
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe(''); // asked once: never a loop
|
|
64
|
-
});
|
|
65
|
-
it('lets the agent stop when only heuristic findings remain', async () => {
|
|
66
|
-
write('src/load.ts', HEURISTIC);
|
|
67
|
-
write('src/main.ts', "import { load } from './load';\nvoid load('/');\n");
|
|
68
|
-
write('package.json', '{"scripts":{"start":"node src/main.ts"}}\n'); // wired in: not dead code
|
|
69
|
-
expect(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's2' }), '/')).toBe('');
|
|
70
|
-
});
|
|
71
|
-
it('says once when the review cannot run, then lets the agent stop', async () => {
|
|
72
|
-
const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'stop-hook-norepo-'));
|
|
73
|
-
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
74
|
-
const payload = JSON.stringify({ cwd: notARepo, session_id: 'broken' });
|
|
75
|
-
const first = JSON.parse(await hooksStopCommand('claude', payload, '/'));
|
|
76
|
-
expect(first).toMatchObject({ decision: 'block' });
|
|
77
|
-
expect(first.reason).toContain('Rigour could not review this change');
|
|
78
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
79
|
-
fs.rmSync(notARepo, { recursive: true, force: true });
|
|
80
|
-
});
|
|
81
|
-
it('still reviews what the session committed', async () => {
|
|
82
|
-
recordSessionBaseline(repo, 's3');
|
|
83
|
-
write('src/notify.ts', LEAKY);
|
|
84
|
-
git('add', '-A');
|
|
85
|
-
git('commit', '-qm', 'hide it in a commit');
|
|
86
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's3' }), '/'));
|
|
87
|
-
expect(reply.reason).toContain('src/notify.ts:2');
|
|
88
|
-
});
|
|
89
|
-
it('reviews the whole branch, including commits made before the session started', async () => {
|
|
90
|
-
git('checkout', '-q', '-b', 'feature');
|
|
91
|
-
write('src/notify.ts', LEAKY);
|
|
92
|
-
git('add', '-A');
|
|
93
|
-
git('commit', '-qm', 'earlier work on the branch');
|
|
94
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'branch' }), '/'));
|
|
95
|
-
expect(reply.reason).toContain('src/notify.ts:2');
|
|
96
|
-
});
|
|
97
|
-
it('says there was nothing to review instead of passing silently', async () => {
|
|
98
|
-
git('checkout', '-q', '-b', 'empty');
|
|
99
|
-
const said = [];
|
|
100
|
-
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
|
101
|
-
expect(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'empty' }), '/')).toBe('');
|
|
102
|
-
expect(said.join('')).toContain('nothing to review against main @');
|
|
103
|
-
});
|
|
104
|
-
it('holds the agent back on an export the branch added that nothing uses', async () => {
|
|
105
|
-
git('checkout', '-q', '-b', 'dead');
|
|
106
|
-
write('src/util.ts', 'export const used = 1;\nexport const forgotten = 2;\n');
|
|
107
|
-
write('src/main.ts', "import { used } from './util';\nconsole.log(used);\n");
|
|
108
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 'dead' }), '/'));
|
|
109
|
-
expect(reply.reason).toContain('src/util.ts:2 Unused export');
|
|
110
|
-
});
|
|
111
|
-
it('does not repeat itself after a turn that changed nothing, and reviews again after an edit', async () => {
|
|
112
|
-
write('src/notify.ts', LEAKY);
|
|
113
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'quiet' });
|
|
114
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
115
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe(''); // a read-only turn: already said
|
|
116
|
-
write('src/notify.ts', LEAKY + '// touched\n');
|
|
117
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
118
|
-
});
|
|
119
|
-
it("does not count Rigour's own files as new work where the repository does not ignore them", async () => {
|
|
120
|
-
write('.gitignore', ''); // a repository that does not ignore .rigour/ or the report
|
|
121
|
-
git('add', '-A');
|
|
122
|
-
git('commit', '-qm', 'ignore nothing');
|
|
123
|
-
write('src/notify.ts', LEAKY);
|
|
124
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'own-files' });
|
|
125
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
126
|
-
write('rigour-report.json', '{"rewritten":true}\n'); // what a review leaves behind
|
|
127
|
-
write('.rigour/scan-cache.json', '{}\n');
|
|
128
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
129
|
-
});
|
|
130
|
-
it('ignores an attempt counter written into the workspace', async () => {
|
|
131
|
-
write('.rigour/stop-hook.json', JSON.stringify({ s4: 3 }));
|
|
132
|
-
write('src/notify.ts', LEAKY);
|
|
133
|
-
const reply = JSON.parse(await hooksStopCommand('claude', JSON.stringify({ cwd: repo, session_id: 's4' }), '/'));
|
|
134
|
-
expect(reply.reason).toContain('attempt 1 of 3');
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
describe('the agent fix loop', () => {
|
|
138
|
-
it('turns an agent fix to a stop-hook finding into a validated rule', async () => {
|
|
139
|
-
const { learnCommand } = await import('./learn.js');
|
|
140
|
-
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-'));
|
|
141
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
142
|
-
git('init', '-q', '-b', 'main');
|
|
143
|
-
git('config', 'user.email', 't@example.com');
|
|
144
|
-
git('config', 'user.name', 't');
|
|
145
|
-
git('config', 'commit.gpgsign', 'false');
|
|
146
|
-
fs.writeFileSync(path.join(repo, '.gitignore'), '.rigour/\n');
|
|
147
|
-
git('add', '-A');
|
|
148
|
-
git('commit', '-qm', 'init');
|
|
149
|
-
fs.mkdirSync(path.join(repo, 'src'));
|
|
150
|
-
fs.writeFileSync(path.join(repo, 'src/notify.ts'), LEAKY);
|
|
151
|
-
fs.writeFileSync(path.join(repo, 'package.json'), '{"scripts":{"notify":"node src/notify.ts"}}\n'); // wired in: not dead code
|
|
152
|
-
const payload = JSON.stringify({ cwd: repo, session_id: 'loop' });
|
|
153
|
-
expect(JSON.parse(await hooksStopCommand('claude', payload, '/')).decision).toBe('block');
|
|
154
|
-
fs.writeFileSync(path.join(repo, 'src/notify.ts'), LEAKY.replace("{ method: 'POST',", "{ method: 'POST', redirect: 'manual',"));
|
|
155
|
-
expect(await hooksStopCommand('claude', payload, '/')).toBe('');
|
|
156
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
157
|
-
await learnCommand(repo, undefined, { agentFixes: true });
|
|
158
|
-
const rules = fs.readdirSync(path.join(repo, '.rigour', 'rules'));
|
|
159
|
-
expect(rules).toHaveLength(1);
|
|
160
|
-
expect(JSON.parse(fs.readFileSync(path.join(repo, '.rigour', 'rules', rules[0]), 'utf8')).pattern)
|
|
161
|
-
.toMatchObject({ template: 'require-option', property: 'redirect', value: 'manual' });
|
|
162
|
-
expect(fs.readdirSync(path.join(repo, '.rigour', 'agent-fixes', 'resolved'))).toEqual([]);
|
|
163
|
-
fs.rmSync(repo, { recursive: true, force: true });
|
|
164
|
-
});
|
|
165
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|