@rigour-labs/core 6.4.3 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deep/agent-review.d.ts +19 -0
- package/dist/deep/agent-review.js +19 -0
- package/dist/deep/agent-review.test.d.ts +1 -0
- package/dist/deep/agent-review.test.js +53 -0
- package/dist/deep/code-pass.d.ts +10 -0
- package/dist/deep/code-pass.js +64 -33
- package/dist/deep/code-pass.test.js +53 -12
- package/dist/deep/code-verifier.d.ts +3 -1
- package/dist/deep/code-verifier.js +11 -11
- package/dist/deep/code-verifier.test.js +8 -0
- package/dist/deep/pr-diff.d.ts +7 -0
- package/dist/deep/pr-diff.js +47 -0
- package/dist/deep/pr-review.d.ts +33 -0
- package/dist/deep/pr-review.js +81 -0
- package/dist/deep/pr-review.test.d.ts +1 -0
- package/dist/deep/pr-review.test.js +80 -0
- package/dist/deep/related-changes.d.ts +13 -0
- package/dist/deep/related-changes.js +56 -0
- package/dist/deep/related-changes.test.d.ts +1 -0
- package/dist/deep/related-changes.test.js +33 -0
- package/dist/deep/review-tools.d.ts +16 -0
- package/dist/deep/review-tools.js +111 -0
- package/dist/deep/review-tools.test.d.ts +1 -0
- package/dist/deep/review-tools.test.js +39 -0
- package/dist/deep/risk.d.ts +41 -0
- package/dist/deep/risk.js +135 -0
- package/dist/deep/risk.test.d.ts +1 -0
- package/dist/deep/risk.test.js +63 -0
- package/dist/deep/router.d.ts +27 -0
- package/dist/deep/router.js +15 -0
- package/dist/deep/self-check.d.ts +14 -9
- package/dist/deep/self-check.js +41 -32
- package/dist/deep/tool-loop.d.ts +19 -0
- package/dist/deep/tool-loop.js +23 -0
- package/dist/gates/deep-analysis.d.ts +27 -0
- package/dist/gates/deep-analysis.js +73 -2
- package/dist/gates/deep-runner.js +14 -0
- package/dist/gates/frontend-secret-exposure.d.ts +2 -0
- package/dist/gates/frontend-secret-exposure.js +8 -0
- package/dist/gates/frontend-secret-exposure.test.js +14 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
- package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
- package/dist/gates/hallucinated-imports/index.js +2 -1
- package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
- package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
- package/dist/gates/phantom-apis.js +2 -2
- package/dist/gates/phantom-apis.test.js +10 -0
- package/dist/gates/promise-safety.js +1 -1
- package/dist/gates/test-quality-matchers.js +3 -0
- package/dist/gates/test-quality.test.js +13 -0
- package/dist/hooks/checker.test.js +9 -0
- package/dist/hooks/stop-review.d.ts +2 -0
- package/dist/hooks/stop-review.js +19 -3
- package/dist/hooks/templates.d.ts +2 -0
- package/dist/hooks/templates.js +16 -2
- package/dist/hooks/templates.test.d.ts +1 -0
- package/dist/hooks/templates.test.js +16 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/inference/cloud-provider.d.ts +12 -1
- package/dist/inference/cloud-provider.js +137 -2
- package/dist/inference/cloud-provider.test.d.ts +1 -0
- package/dist/inference/cloud-provider.test.js +118 -0
- package/dist/inference/pricing.d.ts +15 -0
- package/dist/inference/pricing.js +13 -0
- package/dist/inference/types.d.ts +42 -0
- package/dist/review/changed-function-spans.d.ts +4 -0
- package/dist/review/changed-function-spans.js +48 -0
- package/dist/review/changed-lines.d.ts +9 -3
- package/dist/review/changed-lines.js +19 -2
- package/dist/review/deep-runs.d.ts +22 -0
- package/dist/review/deep-runs.js +59 -0
- package/dist/review/deep-runs.test.d.ts +1 -0
- package/dist/review/deep-runs.test.js +23 -0
- package/dist/review/generated-files.d.ts +3 -0
- package/dist/review/generated-files.js +35 -0
- package/dist/review/generated-files.test.d.ts +1 -0
- package/dist/review/generated-files.test.js +25 -0
- package/dist/review/ledger.d.ts +27 -0
- package/dist/review/ledger.js +72 -0
- package/dist/review/quiet.d.ts +14 -0
- package/dist/review/quiet.js +69 -0
- package/dist/review/quiet.test.d.ts +1 -0
- package/dist/review/quiet.test.js +27 -0
- package/dist/review/review-ack.d.ts +16 -0
- package/dist/review/review-ack.js +25 -0
- package/dist/review/review-task.d.ts +40 -0
- package/dist/review/review-task.js +50 -0
- package/dist/review/review-task.test.d.ts +1 -0
- package/dist/review/review-task.test.js +80 -0
- package/dist/review/review.d.ts +11 -1
- package/dist/review/review.js +15 -6
- package/dist/review/review.test.js +25 -0
- package/dist/review-learning/acted-on.d.ts +25 -0
- package/dist/review-learning/acted-on.js +69 -0
- package/dist/review-learning/learn-from-reviews.d.ts +34 -0
- package/dist/review-learning/learn-from-reviews.js +65 -0
- package/dist/review-learning/lessons.d.ts +52 -0
- package/dist/review-learning/lessons.js +149 -0
- package/dist/review-learning/repo-rules.d.ts +16 -0
- package/dist/review-learning/repo-rules.js +84 -0
- package/dist/review-learning/repo-rules.test.d.ts +1 -0
- package/dist/review-learning/repo-rules.test.js +39 -0
- package/dist/review-learning/review-learning.test.d.ts +1 -0
- package/dist/review-learning/review-learning.test.js +104 -0
- package/dist/review-learning/team-lessons.d.ts +4 -0
- package/dist/review-learning/team-lessons.js +24 -0
- package/dist/semantic/rules/credential-redirect.js +4 -1
- package/dist/services/context-telemetry-service.d.ts +9 -0
- package/dist/services/context-telemetry-service.js +4 -0
- package/dist/services/observed-savings.d.ts +36 -0
- package/dist/services/observed-savings.js +94 -0
- package/dist/services/observed-savings.test.d.ts +1 -0
- package/dist/services/observed-savings.test.js +37 -0
- package/dist/settings.js +4 -1
- package/dist/templates/universal-config.js +7 -3
- package/dist/types/index.d.ts +276 -10
- package/dist/types/index.js +38 -5
- package/package.json +6 -6
|
@@ -13,7 +13,7 @@ import path from 'path';
|
|
|
13
13
|
import ts from 'typescript';
|
|
14
14
|
import { isNodeBuiltin } from '../hallucinated-imports-stdlib.js';
|
|
15
15
|
import { resolveTsPathTarget } from './ts-path-target.js';
|
|
16
|
-
import { isSvelteKitProvided, resolveSvelteKitLib } from './framework-modules.js';
|
|
16
|
+
import { isNuxtProvided, isSvelteKitProvided, resolveSvelteKitLib } from './framework-modules.js';
|
|
17
17
|
import { loadTsPathConfig } from './tsconfig-paths.js';
|
|
18
18
|
/**
|
|
19
19
|
* Check JavaScript/TypeScript imports in a source file and add hallucinated findings.
|
|
@@ -21,6 +21,8 @@ import { loadTsPathConfig } from './tsconfig-paths.js';
|
|
|
21
21
|
export async function checkJSImports(content, file, ctx) {
|
|
22
22
|
const depsForFile = await resolveJSDepsForFile(file, ctx.cwd, ctx.rootDeps, ctx.depCacheByDir);
|
|
23
23
|
const kitRoot = await ctx.kitRoots.rootFor(file);
|
|
24
|
+
const nuxtRoot = await ctx.nuxtRoots?.rootFor(file);
|
|
25
|
+
const typeOnly = typeOnlySpecifiers(content, file);
|
|
24
26
|
for (const spec of collectJSImportSpecs(content, file)) {
|
|
25
27
|
const { line } = spec;
|
|
26
28
|
const importPath = bundlerSpecifier(spec.importPath);
|
|
@@ -28,9 +30,11 @@ export async function checkJSImports(content, file, ctx) {
|
|
|
28
30
|
continue;
|
|
29
31
|
if (kitRoot && isSvelteKitProvided(importPath))
|
|
30
32
|
continue;
|
|
33
|
+
if (nuxtRoot && isNuxtProvided(importPath, ctx.extractPackageName(importPath)))
|
|
34
|
+
continue;
|
|
31
35
|
const reason = importPath.startsWith('.')
|
|
32
36
|
? await checkRelativeImport(file, importPath, ctx)
|
|
33
|
-
: await checkBareImport(file, importPath, depsForFile, kitRoot, ctx);
|
|
37
|
+
: await checkBareImport(file, importPath, depsForFile, kitRoot, ctx, typeOnly.has(spec.importPath));
|
|
34
38
|
if (reason) {
|
|
35
39
|
ctx.hallucinated.push({
|
|
36
40
|
file, line, importPath,
|
|
@@ -40,6 +44,21 @@ export async function checkJSImports(content, file, ctx) {
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
}
|
|
47
|
+
/** Specifiers imported only for types: `import type`, `export type … from`, or anything in a .d.ts file. */
|
|
48
|
+
export function typeOnlySpecifiers(content, file) {
|
|
49
|
+
const all = /\.d\.[cm]?ts$/.test(file);
|
|
50
|
+
const specs = new Set();
|
|
51
|
+
const pattern = all
|
|
52
|
+
? /(?:import|export)[^;]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
53
|
+
: /(?:import|export)\s+type\s[^;]*?from\s*['"]([^'"]+)['"]/g;
|
|
54
|
+
for (const match of content.matchAll(pattern))
|
|
55
|
+
specs.add(match[1] ?? match[2]);
|
|
56
|
+
return specs;
|
|
57
|
+
}
|
|
58
|
+
/** `@types/x` for `x`; `@types/scope__x` for `@scope/x`. */
|
|
59
|
+
export function typesPackageFor(pkgName) {
|
|
60
|
+
return pkgName.startsWith('@') ? `@types/${pkgName.slice(1).replace('/', '__')}` : `@types/${pkgName}`;
|
|
61
|
+
}
|
|
43
62
|
/**
|
|
44
63
|
* Bundler conventions: a query on a relative import (`./logo.svg?raw`, `./worker.ts?worker`)
|
|
45
64
|
* names a transform of that file, so the file is what must exist; a `virtual:` module or a
|
|
@@ -82,7 +101,7 @@ async function resolveThroughRootDirs(file, importPath, rootDirs, ctx) {
|
|
|
82
101
|
return false;
|
|
83
102
|
}
|
|
84
103
|
/** Reason the bare import is hallucinated, or null when it resolves. */
|
|
85
|
-
async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx) {
|
|
104
|
+
async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx, typeOnly = false) {
|
|
86
105
|
const aliasResolution = await resolveTsPathAlias(file, importPath, ctx.cwd, ctx.projectFiles, ctx.tsPathCacheByDir);
|
|
87
106
|
if (aliasResolution === true)
|
|
88
107
|
return null;
|
|
@@ -98,6 +117,9 @@ async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx) {
|
|
|
98
117
|
const pkgName = ctx.extractPackageName(importPath);
|
|
99
118
|
if (isNodeBuiltin(pkgName) || depsForFile.has(pkgName))
|
|
100
119
|
return null;
|
|
120
|
+
// Types for a package can come from DefinitelyTyped; a type-only import needs nothing at runtime.
|
|
121
|
+
if (typeOnly && depsForFile.has(typesPackageFor(pkgName)))
|
|
122
|
+
return null;
|
|
101
123
|
if (ctx.hasNodeModules && await fs.pathExists(path.join(ctx.cwd, 'node_modules', pkgName)))
|
|
102
124
|
return null;
|
|
103
125
|
return `Package '${pkgName}' not in package.json dependencies`;
|
|
@@ -188,7 +188,7 @@ export class PhantomApisGate extends Gate {
|
|
|
188
188
|
continue;
|
|
189
189
|
for (const [alias, moduleName] of moduleAliases) {
|
|
190
190
|
// Match: alias.methodName( or alias.property.something(
|
|
191
|
-
const callPattern = new RegExp(
|
|
191
|
+
const callPattern = new RegExp(`(?<![\\w$.])${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g'); // not `obj.path.split(`
|
|
192
192
|
let match;
|
|
193
193
|
while ((match = callPattern.exec(line)) !== null) {
|
|
194
194
|
const method = match[1];
|
|
@@ -243,7 +243,7 @@ export class PhantomApisGate extends Gate {
|
|
|
243
243
|
for (let i = 0; i < lines.length; i++) {
|
|
244
244
|
const line = this.stripStringLiterals(lines[i]);
|
|
245
245
|
for (const [alias, moduleName] of moduleAliases) {
|
|
246
|
-
const callPattern = new RegExp(
|
|
246
|
+
const callPattern = new RegExp(`(?<![\\w$.])${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g'); // not `obj.path.split(`
|
|
247
247
|
let match;
|
|
248
248
|
while ((match = callPattern.exec(line)) !== null) {
|
|
249
249
|
const method = match[1];
|
|
@@ -58,6 +58,16 @@ const exists = path.exists('/tmp');
|
|
|
58
58
|
expect(failures[0].details).toContain('combine');
|
|
59
59
|
expect(failures[0].details).toContain('exists');
|
|
60
60
|
});
|
|
61
|
+
it('should NOT flag a property that shares the module name', async () => {
|
|
62
|
+
mockFindFiles.mockResolvedValue(['src/generate.ts']);
|
|
63
|
+
mockReadFile.mockResolvedValue(`
|
|
64
|
+
import * as path from 'node:path';
|
|
65
|
+
const tag = proc.path.split('.')[0];
|
|
66
|
+
const dir = path.dirname(file);
|
|
67
|
+
`);
|
|
68
|
+
const failures = await gate.run({ cwd: '/project' });
|
|
69
|
+
expect(failures).toHaveLength(0);
|
|
70
|
+
});
|
|
61
71
|
it('should NOT flag real path methods', async () => {
|
|
62
72
|
mockFindFiles.mockResolvedValue(['src/paths.ts']);
|
|
63
73
|
mockReadFile.mockResolvedValue(`
|
|
@@ -23,7 +23,7 @@ export class PromiseSafetyGate extends Gate {
|
|
|
23
23
|
this.config = {
|
|
24
24
|
enabled: config.enabled ?? true,
|
|
25
25
|
check_unhandled_then: config.check_unhandled_then ?? true,
|
|
26
|
-
check_unsafe_parse: config.check_unsafe_parse ??
|
|
26
|
+
check_unsafe_parse: config.check_unsafe_parse ?? false,
|
|
27
27
|
check_async_without_await: config.check_async_without_await ?? true,
|
|
28
28
|
check_unsafe_fetch: config.check_unsafe_fetch ?? true,
|
|
29
29
|
ignore_patterns: config.ignore_patterns ?? [],
|
|
@@ -10,6 +10,9 @@ export const JS_ASSERTION_PATTERNS = [
|
|
|
10
10
|
/expect\s*\(/,
|
|
11
11
|
/assert\s*[.(]/,
|
|
12
12
|
/\.toEqual|\.toBe|\.toContain|\.toMatch|\.toThrow|\.toHaveBeenCalled|\.toHaveLength|\.toBeTruthy|\.toBeFalsy|\.toBeDefined|\.toBeNull|\.toBeUndefined|\.toBeGreaterThan|\.toBeLessThan|\.toHaveProperty|\.toStrictEqual|\.rejects|\.resolves/,
|
|
13
|
+
// Type-level tests: the compiler is the assertion.
|
|
14
|
+
/expectTypeOf\s*[<(]|assertType\s*[<(]|@ts-expect-error|\bsatisfies\s+[A-Z]/,
|
|
15
|
+
/^\s*(?:const|let)\s+[\w$]+\s*:\s*[^=]+=/,
|
|
13
16
|
];
|
|
14
17
|
/**
|
|
15
18
|
* Regex patterns for detecting mocks in JavaScript/TypeScript tests
|
|
@@ -47,6 +47,19 @@ describe('API', () => {
|
|
|
47
47
|
expect(failures).toHaveLength(1);
|
|
48
48
|
expect(failures[0].details).toContain('no-assertion');
|
|
49
49
|
});
|
|
50
|
+
it('should not flag type-level tests, where the compiler is the assertion', async () => {
|
|
51
|
+
mockFindFiles.mockResolvedValue(['src/types.test.ts']);
|
|
52
|
+
mockReadFile.mockResolvedValue(`
|
|
53
|
+
it('our document type accepts the published one', () => {
|
|
54
|
+
const _published: Document = null as unknown as Published.Document;
|
|
55
|
+
});
|
|
56
|
+
it('infers the output', () => {
|
|
57
|
+
expectTypeOf(output).toEqualTypeOf<string>();
|
|
58
|
+
});
|
|
59
|
+
`);
|
|
60
|
+
const failures = await gate.run({ cwd: '/project' });
|
|
61
|
+
expect(failures).toHaveLength(0);
|
|
62
|
+
});
|
|
50
63
|
it('should flag tautological assertions', async () => {
|
|
51
64
|
mockFindFiles.mockResolvedValue(['src/basic.test.ts']);
|
|
52
65
|
mockReadFile.mockResolvedValue(`
|
|
@@ -34,6 +34,15 @@ describe('runHookChecker', () => {
|
|
|
34
34
|
expect(result.failures).toHaveLength(0);
|
|
35
35
|
expect(result.duration_ms).toBeGreaterThanOrEqual(0);
|
|
36
36
|
});
|
|
37
|
+
it('lets an agent edit CLAUDE.md by default, and blocks it only when the team opts in', async () => {
|
|
38
|
+
const filePath = path.join(testDir, 'CLAUDE.md');
|
|
39
|
+
fs.writeFileSync(filePath, '# Rules\n\n- Use the retry wrapper for partner APIs.\n');
|
|
40
|
+
const byDefault = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
41
|
+
expect(byDefault.failures.filter(f => f.gate === 'governance')).toEqual([]);
|
|
42
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({ version: 1, gates: { governance: { enforce_memory: true } } }));
|
|
43
|
+
const optedIn = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
44
|
+
expect(optedIn.failures.some(f => f.gate === 'governance')).toBe(true);
|
|
45
|
+
});
|
|
37
46
|
it('should detect file size violations', async () => {
|
|
38
47
|
const filePath = path.join(testDir, 'big.ts');
|
|
39
48
|
const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* an agent back; callers also cap the number of attempts.
|
|
10
10
|
*/
|
|
11
11
|
import type { Config, Failure } from '../types/index.js';
|
|
12
|
+
import { type ReviewTaskItem } from '../review/review-task.js';
|
|
12
13
|
export declare const STOP_MAX_ATTEMPTS = 3;
|
|
13
14
|
export interface StopDecision {
|
|
14
15
|
block: boolean;
|
|
@@ -20,6 +21,7 @@ export interface StopDecision {
|
|
|
20
21
|
reviewedFiles: string[];
|
|
21
22
|
}
|
|
22
23
|
export declare function stopReview(cwd: string, config: Config, attempt: number): Promise<StopDecision>;
|
|
24
|
+
export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
|
|
23
25
|
/** Critical, or high and either proven by the semantic engine or a security finding. */
|
|
24
26
|
export declare function blocksStop(finding: Failure): boolean;
|
|
25
27
|
export declare function stopMessage(findings: Failure[], attempt: number): string;
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { reviewChange } from '../review/review.js';
|
|
2
|
+
import { buildReviewTask } from '../review/review-task.js';
|
|
3
|
+
import { diffFromGit } from '../review/git-diff.js';
|
|
2
4
|
export const STOP_MAX_ATTEMPTS = 3;
|
|
3
5
|
const MAX_LISTED = 8;
|
|
4
6
|
export async function stopReview(cwd, config, attempt) {
|
|
5
|
-
const
|
|
7
|
+
const diff = diffFromGit(cwd, { mode: 'working' });
|
|
8
|
+
const result = await reviewChange({ cwd, config, diff });
|
|
6
9
|
const blocking = result.findings.filter(blocksStop);
|
|
10
|
+
const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
|
|
7
11
|
const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
|
|
8
|
-
if (blocking.length === 0)
|
|
12
|
+
if (blocking.length === 0 && unreviewed.length === 0)
|
|
9
13
|
return { block: false, message: '', blocking: 0, ...reviewed };
|
|
10
|
-
|
|
14
|
+
const message = [
|
|
15
|
+
...(blocking.length ? [stopMessage(blocking, attempt)] : []),
|
|
16
|
+
...(unreviewed.length ? [reviewAckMessage(unreviewed, attempt)] : []),
|
|
17
|
+
].join('\n\n');
|
|
18
|
+
return { block: true, message, blocking: blocking.length + unreviewed.length, ...reviewed };
|
|
19
|
+
}
|
|
20
|
+
export function reviewAckMessage(items, attempt) {
|
|
21
|
+
const listed = items.slice(0, MAX_LISTED).map(i => `- ${i.file}:${i.start} \`${i.function}\`: ${i.questions[0]}`);
|
|
22
|
+
return [
|
|
23
|
+
`${items.length} risky changed function(s) have not been reviewed (attempt ${attempt} of ${STOP_MAX_ATTEMPTS}).`,
|
|
24
|
+
'Call rigour_review with mode "agent" for the questions, check each function, then rigour_review_ack it:',
|
|
25
|
+
...listed,
|
|
26
|
+
].join('\n');
|
|
11
27
|
}
|
|
12
28
|
/** Critical, or high and either proven by the semantic engine or a security finding. */
|
|
13
29
|
export function blocksStop(finding) {
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
*/
|
|
17
17
|
import type { HookTool } from './types.js';
|
|
18
|
+
/** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
|
|
19
|
+
export declare function stopCommandFor(checkerCommand: string, tool: 'claude' | 'cursor'): string | undefined;
|
|
18
20
|
export interface GeneratedHookFile {
|
|
19
21
|
path: string;
|
|
20
22
|
content: string;
|
package/dist/hooks/templates.js
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* Both produce functionally equivalent hook configs for each tool.
|
|
15
15
|
*
|
|
16
16
|
*/
|
|
17
|
+
import { STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
18
|
+
/** Seconds Claude Code waits for the stop review before letting the agent stop. */
|
|
19
|
+
const STOP_HOOK_TIMEOUT_S = 120;
|
|
20
|
+
/** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
|
|
21
|
+
export function stopCommandFor(checkerCommand, tool) {
|
|
22
|
+
return /\bhooks check$/.test(checkerCommand.trim()) ? `${checkerCommand.trim().replace(/hooks check$/, 'hooks stop')} --tool ${tool}` : undefined;
|
|
23
|
+
}
|
|
17
24
|
/**
|
|
18
25
|
* Generate hook config files for a specific tool.
|
|
19
26
|
*/
|
|
@@ -44,7 +51,11 @@ function generateClaudeHooks(checkerCommand) {
|
|
|
44
51
|
}
|
|
45
52
|
]
|
|
46
53
|
}
|
|
47
|
-
]
|
|
54
|
+
],
|
|
55
|
+
// Before the agent finishes: review the uncommitted change, as `rigour hooks init` does.
|
|
56
|
+
...(stopCommandFor(checkerCommand, 'claude')
|
|
57
|
+
? { Stop: [{ hooks: [{ type: "command", command: stopCommandFor(checkerCommand, 'claude'), timeout: STOP_HOOK_TIMEOUT_S }] }] }
|
|
58
|
+
: {}),
|
|
48
59
|
}
|
|
49
60
|
};
|
|
50
61
|
return [
|
|
@@ -63,7 +74,10 @@ function generateCursorHooks(checkerCommand) {
|
|
|
63
74
|
{
|
|
64
75
|
command: `${checkerCommand} --stdin`,
|
|
65
76
|
}
|
|
66
|
-
]
|
|
77
|
+
],
|
|
78
|
+
...(stopCommandFor(checkerCommand, 'cursor')
|
|
79
|
+
? { stop: [{ command: stopCommandFor(checkerCommand, 'cursor'), loop_limit: STOP_MAX_ATTEMPTS }] }
|
|
80
|
+
: {}),
|
|
67
81
|
}
|
|
68
82
|
};
|
|
69
83
|
const wrapper = `#!/usr/bin/env node
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateHookFiles, stopCommandFor } from './templates.js';
|
|
3
|
+
const CHECKER = 'npx @rigour-labs/cli@6.5.0 hooks check';
|
|
4
|
+
describe('hook templates', () => {
|
|
5
|
+
it('reviews before the agent stops when the checker is the CLI, like `rigour hooks init`', () => {
|
|
6
|
+
const claude = JSON.parse(generateHookFiles('claude', CHECKER)[0].content);
|
|
7
|
+
expect(claude.hooks.Stop[0].hooks[0]).toEqual({ type: 'command', command: 'npx @rigour-labs/cli@6.5.0 hooks stop --tool claude', timeout: 120 });
|
|
8
|
+
const cursor = JSON.parse(generateHookFiles('cursor', CHECKER).find(f => f.path.endsWith('hooks.json')).content);
|
|
9
|
+
expect(cursor.hooks.stop[0]).toEqual({ command: 'npx @rigour-labs/cli@6.5.0 hooks stop --tool cursor', loop_limit: 3 });
|
|
10
|
+
});
|
|
11
|
+
it('adds no stop hook for a checker that is not the CLI', () => {
|
|
12
|
+
expect(stopCommandFor('node ./my-checker.js', 'claude')).toBeUndefined();
|
|
13
|
+
const claude = JSON.parse(generateHookFiles('claude', 'node ./my-checker.js')[0].content);
|
|
14
|
+
expect(claude.hooks.Stop).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,18 @@ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
14
|
export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
|
|
15
15
|
export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
|
|
16
|
+
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
|
+
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
|
+
export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
|
|
19
|
+
export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
|
|
20
|
+
export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
|
|
21
|
+
export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type FunctionRisk, type RiskSignals } from './deep/risk.js';
|
|
22
|
+
export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.js';
|
|
23
|
+
export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
|
|
24
|
+
export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
|
|
25
|
+
export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath, type ReviewLesson } from './review-learning/lessons.js';
|
|
26
|
+
export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
|
|
27
|
+
export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
|
|
16
28
|
export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
|
|
17
29
|
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
|
|
18
30
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
|
|
@@ -52,6 +64,7 @@ export * from './context/index-bridge.js';
|
|
|
52
64
|
export * from './context/automatic-index.js';
|
|
53
65
|
export * from './context/dependency-graph.js';
|
|
54
66
|
export * from './services/context-telemetry-service.js';
|
|
67
|
+
export * from './services/observed-savings.js';
|
|
55
68
|
export * from './services/system-health.js';
|
|
56
69
|
export * from './services/agent-history.js';
|
|
57
70
|
export * from './services/engineering-knowledge-graph.js';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,18 @@ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
14
|
export { reviewChange, toReviewFinding } from './review/review.js';
|
|
15
15
|
export { splitByChangedLines } from './review/changed-lines.js';
|
|
16
|
+
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
|
+
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
|
+
export { buildReviewTask } from './review/review-task.js';
|
|
19
|
+
export { acknowledgeReview } from './review/review-ack.js';
|
|
20
|
+
export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
|
|
21
|
+
export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './deep/risk.js';
|
|
22
|
+
export { routeFiles } from './deep/router.js';
|
|
23
|
+
export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
|
|
24
|
+
export { learnFromReviews } from './review-learning/learn-from-reviews.js';
|
|
25
|
+
export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
|
|
26
|
+
export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
|
|
27
|
+
export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
|
|
16
28
|
export { diffFromGit, GitDiffError } from './review/git-diff.js';
|
|
17
29
|
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
|
|
18
30
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
|
|
@@ -51,6 +63,7 @@ export * from './context/index-bridge.js';
|
|
|
51
63
|
export * from './context/automatic-index.js';
|
|
52
64
|
export * from './context/dependency-graph.js';
|
|
53
65
|
export * from './services/context-telemetry-service.js';
|
|
66
|
+
export * from './services/observed-savings.js';
|
|
54
67
|
export * from './services/system-health.js';
|
|
55
68
|
export * from './services/agent-history.js';
|
|
56
69
|
export * from './services/engineering-knowledge-graph.js';
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* User provides: api_key + provider name + optional base_url + optional model_name
|
|
13
13
|
* We figure out the rest. Their key, their choice.
|
|
14
14
|
*/
|
|
15
|
-
import type { InferenceProvider, InferenceOptions } from './types.js';
|
|
15
|
+
import type { ChatMessage, ChatReply, ChatTool, InferenceProvider, InferenceOptions, InferenceUsage } from './types.js';
|
|
16
16
|
export declare class CloudProvider implements InferenceProvider {
|
|
17
17
|
readonly name: string;
|
|
18
18
|
private client;
|
|
@@ -21,6 +21,7 @@ export declare class CloudProvider implements InferenceProvider {
|
|
|
21
21
|
private baseUrl?;
|
|
22
22
|
private modelName;
|
|
23
23
|
private isClaude;
|
|
24
|
+
private spent;
|
|
24
25
|
constructor(providerName: string, apiKey: string, options?: {
|
|
25
26
|
baseUrl?: string;
|
|
26
27
|
modelName?: string;
|
|
@@ -28,7 +29,17 @@ export declare class CloudProvider implements InferenceProvider {
|
|
|
28
29
|
isAvailable(): Promise<boolean>;
|
|
29
30
|
setup(onProgress?: (message: string) => void): Promise<void>;
|
|
30
31
|
analyze(prompt: string, options?: InferenceOptions): Promise<string>;
|
|
32
|
+
/** One turn of a tool-using conversation (Anthropic tool use, or OpenAI-compatible function calling). */
|
|
33
|
+
chat(messages: ChatMessage[], tools: ChatTool[], options?: InferenceOptions): Promise<ChatReply>;
|
|
34
|
+
private chatClaude;
|
|
35
|
+
private chatOpenAICompat;
|
|
36
|
+
/** Tokens used since setup, and their cost: provider-reported where given, else list price. */
|
|
37
|
+
usage(): InferenceUsage;
|
|
38
|
+
private record;
|
|
31
39
|
private analyzeClaude;
|
|
32
40
|
private analyzeOpenAICompat;
|
|
41
|
+
/** OpenRouter forwards Anthropic's cache_control to Claude models. */
|
|
42
|
+
private cachesPrompts;
|
|
43
|
+
private isOpenRouter;
|
|
33
44
|
dispose(): void;
|
|
34
45
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { priceTokens } from './pricing.js';
|
|
1
2
|
/** Default models per provider (user can override via model_name) */
|
|
2
3
|
const DEFAULT_MODELS = {
|
|
3
4
|
claude: 'claude-opus-4-6',
|
|
@@ -34,6 +35,7 @@ export class CloudProvider {
|
|
|
34
35
|
baseUrl;
|
|
35
36
|
modelName;
|
|
36
37
|
isClaude;
|
|
38
|
+
spent = { inputTokens: 0, outputTokens: 0, reportedCost: 0, unreportedIn: 0, unreportedOut: 0 };
|
|
37
39
|
constructor(providerName, apiKey, options) {
|
|
38
40
|
if (!apiKey || apiKey.trim().length === 0) {
|
|
39
41
|
throw new Error(`API key cannot be empty for provider "${providerName}"`);
|
|
@@ -89,6 +91,62 @@ export class CloudProvider {
|
|
|
89
91
|
return this.analyzeOpenAICompat(prompt, options);
|
|
90
92
|
}
|
|
91
93
|
}
|
|
94
|
+
/** One turn of a tool-using conversation (Anthropic tool use, or OpenAI-compatible function calling). */
|
|
95
|
+
async chat(messages, tools, options) {
|
|
96
|
+
if (!this.client)
|
|
97
|
+
throw new Error('Provider not set up. Call setup() first.');
|
|
98
|
+
return this.isClaude ? this.chatClaude(messages, tools, options) : this.chatOpenAICompat(messages, tools, options);
|
|
99
|
+
}
|
|
100
|
+
async chatClaude(messages, tools, options) {
|
|
101
|
+
const response = await this.client.messages.create({
|
|
102
|
+
model: this.modelName,
|
|
103
|
+
max_tokens: options?.maxTokens || 4096,
|
|
104
|
+
temperature: options?.temperature ?? 0.1,
|
|
105
|
+
messages: cacheFirstPrompt(toAnthropicMessages(messages)),
|
|
106
|
+
...(tools.length ? { tools: tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) } : {}),
|
|
107
|
+
...(tools.length && options?.toolChoice === 'none' ? { tool_choice: { type: 'none' } } : {}),
|
|
108
|
+
}, requestOptions(options));
|
|
109
|
+
this.record(response.usage?.input_tokens, response.usage?.output_tokens);
|
|
110
|
+
const blocks = response.content ?? [];
|
|
111
|
+
return {
|
|
112
|
+
text: blocks.filter(b => b.type === 'text').map(b => b.text).join('\n'),
|
|
113
|
+
toolCalls: blocks.filter(b => b.type === 'tool_use').map(b => ({ id: b.id, name: b.name, arguments: b.input ?? {} })),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async chatOpenAICompat(messages, tools, options) {
|
|
117
|
+
const response = await this.client.chat.completions.create({
|
|
118
|
+
model: this.modelName,
|
|
119
|
+
max_tokens: options?.maxTokens || 4096,
|
|
120
|
+
temperature: options?.temperature ?? 0.1,
|
|
121
|
+
messages: this.cachesPrompts() ? cacheFirstPrompt(toOpenAIMessages(messages)) : toOpenAIMessages(messages),
|
|
122
|
+
...(tools.length ? { tools: tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } })) } : {}),
|
|
123
|
+
...(tools.length && options?.toolChoice === 'none' ? { tool_choice: 'none' } : {}),
|
|
124
|
+
...(this.isOpenRouter() ? { usage: { include: true } } : {}),
|
|
125
|
+
}, requestOptions(options));
|
|
126
|
+
this.record(response.usage?.prompt_tokens, response.usage?.completion_tokens, response.usage?.cost);
|
|
127
|
+
const message = response.choices[0]?.message ?? {};
|
|
128
|
+
return {
|
|
129
|
+
text: message.content ?? '',
|
|
130
|
+
toolCalls: (message.tool_calls ?? []).map((c) => ({ id: c.id, name: c.function?.name, arguments: parseArguments(c.function?.arguments) })),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Tokens used since setup, and their cost: provider-reported where given, else list price. */
|
|
134
|
+
usage() {
|
|
135
|
+
const { inputTokens, outputTokens, reportedCost, unreportedIn, unreportedOut } = this.spent;
|
|
136
|
+
const priced = unreportedIn + unreportedOut === 0 ? 0 : priceTokens(this.modelName, unreportedIn, unreportedOut);
|
|
137
|
+
return { inputTokens, outputTokens, ...(priced === undefined ? {} : { costUsd: reportedCost + priced }) };
|
|
138
|
+
}
|
|
139
|
+
record(inputTokens = 0, outputTokens = 0, reportedCost) {
|
|
140
|
+
this.spent.inputTokens += inputTokens;
|
|
141
|
+
this.spent.outputTokens += outputTokens;
|
|
142
|
+
if (typeof reportedCost === 'number') {
|
|
143
|
+
this.spent.reportedCost += reportedCost;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
this.spent.unreportedIn += inputTokens;
|
|
147
|
+
this.spent.unreportedOut += outputTokens;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
92
150
|
async analyzeClaude(prompt, options) {
|
|
93
151
|
const response = await this.client.messages.create({
|
|
94
152
|
model: this.modelName,
|
|
@@ -97,7 +155,8 @@ export class CloudProvider {
|
|
|
97
155
|
messages: [
|
|
98
156
|
{ role: 'user', content: prompt }
|
|
99
157
|
],
|
|
100
|
-
});
|
|
158
|
+
}, requestOptions(options));
|
|
159
|
+
this.record(response.usage?.input_tokens, response.usage?.output_tokens);
|
|
101
160
|
const textBlock = response.content.find((b) => b.type === 'text');
|
|
102
161
|
if (!textBlock?.text) {
|
|
103
162
|
throw new Error(`Empty response from ${this.providerName} API (model: ${this.modelName}). Response had ${response.content.length} blocks but no text.`);
|
|
@@ -113,14 +172,90 @@ export class CloudProvider {
|
|
|
113
172
|
{ role: 'user', content: prompt }
|
|
114
173
|
],
|
|
115
174
|
...(options?.jsonMode ? { response_format: { type: 'json_object' } } : {}),
|
|
116
|
-
|
|
175
|
+
// OpenRouter reports the call's real cost when asked; other endpoints may reject the field.
|
|
176
|
+
...(this.isOpenRouter() ? { usage: { include: true } } : {}),
|
|
177
|
+
}, requestOptions(options));
|
|
178
|
+
this.record(response.usage?.prompt_tokens, response.usage?.completion_tokens, response.usage?.cost);
|
|
117
179
|
const content = response.choices[0]?.message?.content;
|
|
118
180
|
if (!content) {
|
|
119
181
|
throw new Error(`Empty response from ${this.providerName} API (model: ${this.modelName}). No content in choices.`);
|
|
120
182
|
}
|
|
121
183
|
return content;
|
|
122
184
|
}
|
|
185
|
+
/** OpenRouter forwards Anthropic's cache_control to Claude models. */
|
|
186
|
+
cachesPrompts() {
|
|
187
|
+
return this.isOpenRouter() && /(^|\/)(anthropic\/|claude)/i.test(this.modelName);
|
|
188
|
+
}
|
|
189
|
+
isOpenRouter() {
|
|
190
|
+
return /(^|\.)openrouter\.ai(\/|$)/.test((this.baseUrl || '').replace(/^https?:\/\//, ''));
|
|
191
|
+
}
|
|
123
192
|
dispose() {
|
|
124
193
|
this.client = null;
|
|
125
194
|
}
|
|
126
195
|
}
|
|
196
|
+
/** The per-call timeout, honoured by both SDKs (their default is minutes, with retries). */
|
|
197
|
+
function requestOptions(options) {
|
|
198
|
+
return options?.timeout ? { timeout: options.timeout } : {};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Mark the first prompt for caching: a tool conversation resends it every
|
|
202
|
+
* turn, and it is most of the input (the diff and the instructions).
|
|
203
|
+
*/
|
|
204
|
+
function cacheFirstPrompt(messages) {
|
|
205
|
+
const [first, ...rest] = messages;
|
|
206
|
+
if (!first || first.role !== 'user' || typeof first.content !== 'string')
|
|
207
|
+
return messages;
|
|
208
|
+
return [{ role: 'user', content: [{ type: 'text', text: first.content, cache_control: { type: 'ephemeral' } }] }, ...rest];
|
|
209
|
+
}
|
|
210
|
+
/** Anthropic wants tool results as user turns, consecutive results grouped into one. */
|
|
211
|
+
function toAnthropicMessages(messages) {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const message of messages) {
|
|
214
|
+
if (message.role === 'tool') {
|
|
215
|
+
const block = { type: 'tool_result', tool_use_id: message.toolCallId, content: message.content };
|
|
216
|
+
const last = out[out.length - 1];
|
|
217
|
+
if (last?.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result')
|
|
218
|
+
last.content.push(block);
|
|
219
|
+
else
|
|
220
|
+
out.push({ role: 'user', content: [block] });
|
|
221
|
+
}
|
|
222
|
+
else if (message.role === 'assistant') {
|
|
223
|
+
const content = message.content ? [{ type: 'text', text: message.content }] : [];
|
|
224
|
+
for (const call of message.toolCalls ?? [])
|
|
225
|
+
content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments });
|
|
226
|
+
out.push({ role: 'assistant', content });
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
out.push({ role: 'user', content: message.content });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
function toOpenAIMessages(messages) {
|
|
235
|
+
return messages.map((message) => {
|
|
236
|
+
if (message.role === 'tool')
|
|
237
|
+
return { role: 'tool', tool_call_id: message.toolCallId, content: message.content };
|
|
238
|
+
if (message.role === 'assistant') {
|
|
239
|
+
return {
|
|
240
|
+
role: 'assistant',
|
|
241
|
+
content: message.content || null,
|
|
242
|
+
...(message.toolCalls?.length ? {
|
|
243
|
+
tool_calls: message.toolCalls.map(c => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.arguments) } })),
|
|
244
|
+
} : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { role: 'user', content: message.content };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/** Function-call arguments arrive as a JSON string; a malformed one becomes no arguments. */
|
|
251
|
+
function parseArguments(raw) {
|
|
252
|
+
if (typeof raw !== 'string')
|
|
253
|
+
return {};
|
|
254
|
+
try {
|
|
255
|
+
const value = JSON.parse(raw);
|
|
256
|
+
return value && typeof value === 'object' ? value : {};
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|