@rigour-labs/core 6.6.6 → 6.7.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/README.md +16 -92
- package/dist/environment.test.js +0 -4
- package/dist/gates/agent-team.d.ts +0 -2
- package/dist/gates/agent-team.js +0 -8
- package/dist/gates/checkpoint.d.ts +0 -1
- package/dist/gates/checkpoint.js +0 -1
- package/dist/gates/context.d.ts +0 -4
- package/dist/gates/context.js +7 -17
- package/dist/gates/deep-analysis.d.ts +0 -1
- package/dist/gates/deep-runner.js +0 -1
- package/dist/gates/frontend-secret-exposure.test.js +2 -2
- package/dist/gates/hallucinated-imports/index.d.ts +0 -2
- package/dist/gates/hallucinated-imports/index.js +2 -2
- package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/runner.js +3 -4
- package/dist/gates/test-quality-matchers.d.ts +0 -4
- package/dist/gates/test-quality-matchers.js +0 -7
- package/dist/gates/test-quality.d.ts +0 -2
- package/dist/gates/test-quality.js +1 -10
- package/dist/hooks/checker.js +1 -1
- package/dist/hooks/index.d.ts +1 -2
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/cloud-provider.d.ts +2 -0
- package/dist/inference/cloud-provider.js +16 -6
- package/dist/inference/cloud-provider.test.js +21 -0
- package/dist/pattern-index/embeddings.d.ts +0 -5
- package/dist/pattern-index/embeddings.js +11 -4
- package/dist/pattern-index/semantic-runtime.d.ts +10 -0
- package/dist/pattern-index/semantic-runtime.js +46 -0
- package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
- package/dist/pattern-index/semantic-runtime.test.js +31 -0
- package/dist/review/backtest-judges.d.ts +38 -0
- package/dist/review/backtest-judges.js +63 -0
- package/dist/review/backtest-judges.test.d.ts +1 -0
- package/dist/review/backtest-judges.test.js +32 -0
- package/dist/review/backtest.d.ts +9 -4
- package/dist/review/backtest.js +19 -9
- package/dist/review/backtest.test.js +2 -2
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +7 -0
- package/dist/review/fix-scope.d.ts +21 -0
- package/dist/review/fix-scope.js +94 -0
- package/dist/review/fix-scope.test.d.ts +1 -0
- package/dist/review/fix-scope.test.js +104 -0
- package/dist/review/lint-overlay.js +1 -1
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/partial-wiring.js +5 -3
- package/dist/review/query-patterns.js +3 -2
- package/dist/review/review.js +2 -1
- package/dist/review/reviewer/adapters.d.ts +23 -7
- package/dist/review/reviewer/adapters.js +32 -7
- package/dist/review/reviewer/adapters.test.d.ts +1 -0
- package/dist/review/reviewer/adapters.test.js +23 -0
- package/dist/review/reviewer/background.d.ts +8 -1
- package/dist/review/reviewer/background.js +34 -9
- package/dist/review/reviewer/background.test.js +16 -2
- package/dist/review/reviewer/consensus.d.ts +36 -0
- package/dist/review/reviewer/consensus.js +144 -0
- package/dist/review/reviewer/context.d.ts +75 -0
- package/dist/review/reviewer/context.js +140 -0
- package/dist/review/reviewer/context.test.d.ts +1 -0
- package/dist/review/reviewer/context.test.js +18 -0
- package/dist/review/reviewer/exec.js +6 -2
- package/dist/review/reviewer/exec.test.d.ts +1 -0
- package/dist/review/reviewer/exec.test.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +2 -0
- package/dist/review/reviewer/inputs.js +5 -1
- package/dist/review/reviewer/panel.d.ts +67 -0
- package/dist/review/reviewer/panel.js +101 -0
- package/dist/review/reviewer/panel.test.d.ts +1 -0
- package/dist/review/reviewer/panel.test.js +110 -0
- package/dist/review/reviewer/prompt.d.ts +11 -0
- package/dist/review/reviewer/prompt.js +26 -4
- package/dist/review/reviewer/settings.d.ts +58 -0
- package/dist/review/reviewer/settings.js +152 -0
- package/dist/review/reviewer/settings.test.d.ts +1 -0
- package/dist/review/reviewer/settings.test.js +57 -0
- package/dist/review/reviewer/store.d.ts +25 -2
- package/dist/review/reviewer/store.js +43 -1
- package/dist/review/reviewer/usage.d.ts +2 -0
- package/dist/review/reviewer/usage.js +28 -0
- package/dist/review/reviewer/usage.test.d.ts +1 -0
- package/dist/review/reviewer/usage.test.js +14 -0
- package/dist/review/reviewer/verdict.d.ts +15 -1
- package/dist/review/reviewer/verdict.js +17 -7
- package/dist/review/reviewer.d.ts +45 -2
- package/dist/review/reviewer.js +186 -25
- package/dist/review/reviewer.test.js +127 -12
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.js +10 -3
- package/dist/review/toolchain.test.js +6 -3
- package/dist/review/typed/program.d.ts +0 -1
- package/dist/review/typed/program.js +3 -3
- package/dist/review/typed/redundancy.js +43 -4
- package/dist/review/typed/redundancy.test.js +58 -0
- package/dist/review/typed/schema-nullability.d.ts +6 -0
- package/dist/review/typed/schema-nullability.js +218 -0
- package/dist/review/typed/schema-nullability.test.d.ts +1 -0
- package/dist/review/typed/schema-nullability.test.js +63 -0
- package/dist/review/unused-exports.js +5 -6
- package/dist/semantic/learn/types.d.ts +2 -2
- package/dist/services/adaptive-thresholds.d.ts +2 -52
- package/dist/services/adaptive-thresholds.js +2 -147
- package/dist/services/adaptive-thresholds.test.js +1 -81
- package/dist/settings.d.ts +14 -6
- package/dist/settings.js +0 -12
- package/dist/storage/team-diagnostics.js +1 -1
- package/dist/storage/team-schema.d.ts +1 -1
- package/dist/storage/team-schema.js +2 -0
- package/dist/storage/team-store.js +26 -3
- package/dist/storage/team-sync-scope.test.js +35 -2
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +11 -1
- package/dist/templates/paradigms.js +0 -8
- package/dist/templates/presets.d.ts +0 -1
- package/dist/templates/presets.js +0 -23
- package/dist/templates/universal-config.js +8 -45
- package/dist/types/index.d.ts +108 -566
- package/dist/types/index.js +41 -66
- package/dist/types/index.test.d.ts +1 -0
- package/dist/types/index.test.js +33 -0
- package/dist/utils/command-line.d.ts +8 -0
- package/dist/utils/command-line.js +8 -0
- package/dist/utils/command-line.test.d.ts +1 -0
- package/dist/utils/command-line.test.js +8 -0
- package/dist/utils/profile.js +1 -1
- package/package.json +6 -7
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { priceTokens } from './pricing.js';
|
|
2
2
|
/** Default models per provider (user can override via model_name) */
|
|
3
3
|
const DEFAULT_MODELS = {
|
|
4
|
-
claude: 'claude-
|
|
5
|
-
anthropic: 'claude-sonnet-
|
|
4
|
+
claude: 'claude-sonnet-5-5',
|
|
5
|
+
anthropic: 'claude-sonnet-5-5',
|
|
6
6
|
openai: 'gpt-5-mini',
|
|
7
7
|
gemini: 'gemini-2.5-flash',
|
|
8
8
|
groq: 'llama-3.3-70b-versatile',
|
|
@@ -24,6 +24,7 @@ const DEFAULT_BASE_URLS = {
|
|
|
24
24
|
fireworks: 'https://api.fireworks.ai/inference/v1',
|
|
25
25
|
deepseek: 'https://api.deepseek.com/v1',
|
|
26
26
|
perplexity: 'https://api.perplexity.ai',
|
|
27
|
+
openrouter: 'https://openrouter.ai/api/v1',
|
|
27
28
|
ollama: 'http://localhost:11434/v1',
|
|
28
29
|
lmstudio: 'http://localhost:1234/v1',
|
|
29
30
|
};
|
|
@@ -43,7 +44,11 @@ export class CloudProvider {
|
|
|
43
44
|
this.providerName = providerName.toLowerCase();
|
|
44
45
|
this.apiKey = apiKey.trim();
|
|
45
46
|
this.baseUrl = options?.baseUrl;
|
|
46
|
-
|
|
47
|
+
const model = options?.modelName || DEFAULT_MODELS[this.providerName];
|
|
48
|
+
// No guessed model: a default that does not exist on this provider fails every call, or bills the wrong one.
|
|
49
|
+
if (!model)
|
|
50
|
+
throw new Error(`No default model for provider "${this.providerName}": pass --model-name (for example rigour settings set deep.defaultModel <name>).`);
|
|
51
|
+
this.modelName = model;
|
|
47
52
|
this.isClaude = this.providerName === 'claude' || this.providerName === 'anthropic';
|
|
48
53
|
this.name = `cloud-${this.providerName}`;
|
|
49
54
|
}
|
|
@@ -54,7 +59,8 @@ export class CloudProvider {
|
|
|
54
59
|
if (this.isClaude) {
|
|
55
60
|
try {
|
|
56
61
|
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
57
|
-
|
|
62
|
+
// A base URL given for Claude (a proxy, a gateway) is honoured like any other provider's.
|
|
63
|
+
this.client = new Anthropic({ apiKey: this.apiKey, ...(this.baseUrl ? { baseURL: this.baseUrl } : {}) });
|
|
58
64
|
onProgress?.(`✓ ${this.providerName} API connected (model: ${this.modelName})`);
|
|
59
65
|
}
|
|
60
66
|
catch {
|
|
@@ -68,7 +74,7 @@ export class CloudProvider {
|
|
|
68
74
|
// No limitations. User's key, user's choice.
|
|
69
75
|
try {
|
|
70
76
|
const { default: OpenAI } = await import('openai');
|
|
71
|
-
const baseURL = this.
|
|
77
|
+
const baseURL = this.endpoint() || undefined;
|
|
72
78
|
this.client = new OpenAI({
|
|
73
79
|
apiKey: this.apiKey,
|
|
74
80
|
...(baseURL ? { baseURL } : {}),
|
|
@@ -187,7 +193,11 @@ export class CloudProvider {
|
|
|
187
193
|
return this.isOpenRouter() && /(^|\/)(anthropic\/|claude)/i.test(this.modelName);
|
|
188
194
|
}
|
|
189
195
|
isOpenRouter() {
|
|
190
|
-
return /(^|\.)openrouter\.ai(\/|$)/.test(
|
|
196
|
+
return /(^|\.)openrouter\.ai(\/|$)/.test(this.endpoint().replace(/^https?:\/\//, ''));
|
|
197
|
+
}
|
|
198
|
+
/** The API base URL: the one given, else the provider's own. */
|
|
199
|
+
endpoint() {
|
|
200
|
+
return this.baseUrl || DEFAULT_BASE_URLS[this.providerName] || '';
|
|
191
201
|
}
|
|
192
202
|
dispose() {
|
|
193
203
|
this.client = null;
|
|
@@ -8,6 +8,10 @@ vi.mock('openai', () => ({
|
|
|
8
8
|
}));
|
|
9
9
|
vi.mock('@anthropic-ai/sdk', () => ({
|
|
10
10
|
default: class {
|
|
11
|
+
options;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
11
15
|
messages = { create: anthropicCreate };
|
|
12
16
|
},
|
|
13
17
|
}));
|
|
@@ -18,6 +22,23 @@ beforeEach(() => {
|
|
|
18
22
|
anthropicCreate.mockReset();
|
|
19
23
|
});
|
|
20
24
|
describe('CloudProvider', () => {
|
|
25
|
+
it('names no model it cannot know: a provider without a default needs one', () => {
|
|
26
|
+
expect(() => new CloudProvider('somecloud', 'k')).toThrow(/pass --model-name/);
|
|
27
|
+
expect(() => new CloudProvider('somecloud', 'k', { modelName: 'x' })).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
it('sends Claude calls to a base URL when one is given', async () => {
|
|
30
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 } });
|
|
31
|
+
const provider = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5', baseUrl: 'https://gateway.example/anthropic' });
|
|
32
|
+
await provider.setup();
|
|
33
|
+
expect(provider.client.options).toMatchObject({ baseURL: 'https://gateway.example/anthropic' });
|
|
34
|
+
});
|
|
35
|
+
it('sends OpenRouter calls to OpenRouter, asking it to report their cost', async () => {
|
|
36
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } });
|
|
37
|
+
const provider = new CloudProvider('openrouter', 'k', { modelName: 'anthropic/claude-sonnet-5.5' });
|
|
38
|
+
await provider.setup();
|
|
39
|
+
await provider.analyze('prompt');
|
|
40
|
+
expect(openaiCreate.mock.calls[0][0]).toMatchObject({ usage: { include: true } });
|
|
41
|
+
});
|
|
21
42
|
it('passes the per-call timeout to the SDK request', async () => {
|
|
22
43
|
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 10, output_tokens: 2 } });
|
|
23
44
|
const provider = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5' });
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Semantic Embedding Service
|
|
3
3
|
*
|
|
4
|
-
* Uses Transformers.js for local vector embeddings
|
|
4
|
+
* Uses Transformers.js for local vector embeddings, loaded from wherever it is installed
|
|
5
|
+
* (semantic-runtime.ts). Without it, embeddings are empty and callers fall back to keywords.
|
|
5
6
|
*/
|
|
7
|
+
import { loadTransformers } from './semantic-runtime.js';
|
|
6
8
|
/**
|
|
7
9
|
* Singleton for the embedding pipeline to avoid re-loading the model.
|
|
8
10
|
*/
|
|
@@ -26,10 +28,15 @@ async function getPipeline() {
|
|
|
26
28
|
return null;
|
|
27
29
|
if (!embeddingPipeline) {
|
|
28
30
|
try {
|
|
29
|
-
//
|
|
30
|
-
const
|
|
31
|
+
// Loaded where it is installed; never a dependency of the npm package (semantic-runtime.ts).
|
|
32
|
+
const transformers = await loadTransformers();
|
|
33
|
+
if (!transformers) {
|
|
34
|
+
// Not installed: keyword matching, said once by `rigour doctor`, not on every run.
|
|
35
|
+
embeddingUnavailable = true;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
31
38
|
// Using a compact but high-quality model for local embeddings
|
|
32
|
-
embeddingPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
39
|
+
embeddingPipeline = await transformers.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
33
40
|
}
|
|
34
41
|
catch (error) {
|
|
35
42
|
embeddingUnavailable = true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The version installed by `rigour setup`; the model it loads is fetched once on first use. */
|
|
2
|
+
export declare const TRANSFORMERS_SPEC = "@xenova/transformers@2.17.2";
|
|
3
|
+
/** Rigour's own copy, shared by every Rigour version on the machine (per profile home). */
|
|
4
|
+
export declare function semanticRuntimeDir(): string;
|
|
5
|
+
/** Where the library resolves from, or undefined when it is not installed anywhere Rigour looks. */
|
|
6
|
+
export declare function locateTransformers(cwd?: string): string | undefined;
|
|
7
|
+
/** The library's module, loaded from wherever it is installed. */
|
|
8
|
+
export declare function loadTransformers(cwd?: string): Promise<any | undefined>;
|
|
9
|
+
/** Whether Rigour's home has its own copy (what `rigour setup` installs). */
|
|
10
|
+
export declare function semanticRuntimeInstalled(): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the local embedding library (`@xenova/transformers`, with its ONNX runtime about 230 MB)
|
|
3
|
+
* comes from. It is not a dependency of the npm package: npm installed it again for every
|
|
4
|
+
* version a hook pinned, so a machine kept one copy per Rigour version. `rigour setup` installs it
|
|
5
|
+
* once into Rigour's home, where every version finds it.
|
|
6
|
+
*
|
|
7
|
+
* Search order: next to Rigour itself (a checkout, or a project that installs Rigour and the
|
|
8
|
+
* library as devDependencies), the project being worked on, then Rigour's home. Without it,
|
|
9
|
+
* recall and pattern matching fall back to keywords, which find far less (on the committed eval
|
|
10
|
+
* sets, 6% of memories and 20% of existing helpers, against 81% and 93% by meaning).
|
|
11
|
+
*/
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { pathToFileURL } from 'url';
|
|
16
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
17
|
+
/** The version installed by `rigour setup`; the model it loads is fetched once on first use. */
|
|
18
|
+
export const TRANSFORMERS_SPEC = '@xenova/transformers@2.17.2';
|
|
19
|
+
const PACKAGE = '@xenova/transformers';
|
|
20
|
+
/** Rigour's own copy, shared by every Rigour version on the machine (per profile home). */
|
|
21
|
+
export function semanticRuntimeDir() {
|
|
22
|
+
return path.join(rigourUserDir(), 'runtime', 'semantic');
|
|
23
|
+
}
|
|
24
|
+
/** Where the library resolves from, or undefined when it is not installed anywhere Rigour looks. */
|
|
25
|
+
export function locateTransformers(cwd = process.cwd()) {
|
|
26
|
+
for (const base of [import.meta.url, path.join(cwd, 'package.json'), path.join(semanticRuntimeDir(), 'package.json')]) {
|
|
27
|
+
try {
|
|
28
|
+
return createRequire(base).resolve(PACKAGE);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// not there
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The library's module, loaded from wherever it is installed. */
|
|
37
|
+
export async function loadTransformers(cwd = process.cwd()) {
|
|
38
|
+
const entry = locateTransformers(cwd);
|
|
39
|
+
if (!entry)
|
|
40
|
+
return undefined;
|
|
41
|
+
return import(pathToFileURL(entry).href);
|
|
42
|
+
}
|
|
43
|
+
/** Whether Rigour's home has its own copy (what `rigour setup` installs). */
|
|
44
|
+
export function semanticRuntimeInstalled() {
|
|
45
|
+
return fs.existsSync(path.join(semanticRuntimeDir(), 'node_modules', ...PACKAGE.split('/'), 'package.json'));
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { locateTransformers, semanticRuntimeDir, semanticRuntimeInstalled } from './semantic-runtime.js';
|
|
6
|
+
let home;
|
|
7
|
+
const original = process.env.RIGOUR_HOME;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'semantic-home-'));
|
|
10
|
+
process.env.RIGOUR_HOME = home;
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
process.env.RIGOUR_HOME = original;
|
|
14
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe('the semantic runtime', () => {
|
|
17
|
+
it('lives in Rigour\'s home, shared by every Rigour version, and is seen there once installed', () => {
|
|
18
|
+
expect(semanticRuntimeDir()).toBe(path.join(home, '.rigour', 'runtime', 'semantic'));
|
|
19
|
+
expect(semanticRuntimeInstalled()).toBe(false);
|
|
20
|
+
const pkg = path.join(semanticRuntimeDir(), 'node_modules', '@xenova', 'transformers');
|
|
21
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: '@xenova/transformers', main: 'index.js' }));
|
|
23
|
+
fs.writeFileSync(path.join(pkg, 'index.js'), 'module.exports = {};');
|
|
24
|
+
expect(semanticRuntimeInstalled()).toBe(true);
|
|
25
|
+
expect(locateTransformers(home)).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
it('is not a dependency of the published package: the 240 MB is installed once, never per version', () => {
|
|
28
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
|
|
29
|
+
expect({ ...manifest.dependencies, ...manifest.optionalDependencies }).not.toHaveProperty('@xenova/transformers');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel measured against the ledger, per judge: which human points each judge raised on its
|
|
3
|
+
* own, how often two judges agree (Cohen's kappa over the ledger's points, where the set of items
|
|
4
|
+
* is fixed, so agreeing on "not raised" is meaningful), and what a caught point cost. A high kappa
|
|
5
|
+
* means the judges share blind spots and a second one adds little; a judge whose catches the others
|
|
6
|
+
* always make too is not earning its runs. This decides one, two or three judges with data.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReviewerResult } from './reviewer.js';
|
|
9
|
+
/** What each judge raised unprompted in one round, with what the round cost. */
|
|
10
|
+
export interface JudgeRun {
|
|
11
|
+
judges: string[];
|
|
12
|
+
raised: Array<{
|
|
13
|
+
judge: string;
|
|
14
|
+
file: string;
|
|
15
|
+
line: number | undefined;
|
|
16
|
+
text: string;
|
|
17
|
+
}>;
|
|
18
|
+
costUsd?: number;
|
|
19
|
+
runs: number;
|
|
20
|
+
}
|
|
21
|
+
export interface JudgeCatches {
|
|
22
|
+
judges: string[];
|
|
23
|
+
/** Ledger point ids each judge raised. */
|
|
24
|
+
caught: Record<string, string[]>;
|
|
25
|
+
points: string[];
|
|
26
|
+
costUsd?: number;
|
|
27
|
+
runs: number;
|
|
28
|
+
}
|
|
29
|
+
/** Each judge's own raises: the panel's record when there was one, else the items tagged with the judge who found them. */
|
|
30
|
+
export declare function judgedFrom(result: ReviewerResult): JudgeRun | undefined;
|
|
31
|
+
/** Across rounds: each judge's catches, every pair's kappa, and the cost of a caught point. */
|
|
32
|
+
export declare function formatJudges(rounds: Array<{
|
|
33
|
+
judges?: JudgeCatches;
|
|
34
|
+
points: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
caught: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
}>): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Each judge's own raises: the panel's record when there was one, else the items tagged with the judge who found them. */
|
|
2
|
+
export function judgedFrom(result) {
|
|
3
|
+
if (result.reviewers.length < 2)
|
|
4
|
+
return undefined;
|
|
5
|
+
const raised = [];
|
|
6
|
+
if (result.panel) {
|
|
7
|
+
for (const d of result.panel) {
|
|
8
|
+
for (const judge of d.judges) {
|
|
9
|
+
const own = d.members?.find(m => m.judge === judge)?.item ?? d.item;
|
|
10
|
+
raised.push({ judge, file: own.file ?? '', line: own.line, text: [own.issue, own.consequence, own.evidence].filter(Boolean).join(' ') });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
for (const item of [...result.items, ...result.unverified, ...result.notes]) {
|
|
16
|
+
if (item.kind === 'prior' || !item.reviewer)
|
|
17
|
+
continue;
|
|
18
|
+
for (const judge of item.reviewer.split('+'))
|
|
19
|
+
raised.push({ judge, file: item.file ?? '', line: item.line, text: [item.issue, item.consequence, item.evidence].filter(Boolean).join(' ') });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { judges: result.reviewers, raised, ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {}), runs: result.runs ?? result.reviewers.length };
|
|
23
|
+
}
|
|
24
|
+
/** Cohen's kappa for two raters' yes/no calls on the same items; 1 when they agree on everything, 0 at chance. */
|
|
25
|
+
function cohensKappa(a, b) {
|
|
26
|
+
const n = a.length;
|
|
27
|
+
if (n === 0 || n !== b.length)
|
|
28
|
+
return Number.NaN;
|
|
29
|
+
const agree = a.filter((x, i) => x === b[i]).length / n;
|
|
30
|
+
const pa = a.filter(Boolean).length / n;
|
|
31
|
+
const pb = b.filter(Boolean).length / n;
|
|
32
|
+
const chance = pa * pb + (1 - pa) * (1 - pb);
|
|
33
|
+
return chance === 1 ? (agree === 1 ? 1 : 0) : (agree - chance) / (1 - chance);
|
|
34
|
+
}
|
|
35
|
+
/** Across rounds: each judge's catches, every pair's kappa, and the cost of a caught point. */
|
|
36
|
+
export function formatJudges(rounds) {
|
|
37
|
+
const judged = rounds.map(r => r.judges).filter((j) => !!j);
|
|
38
|
+
if (!judged.length)
|
|
39
|
+
return '';
|
|
40
|
+
const names = [...new Set(judged.flatMap(j => j.judges))];
|
|
41
|
+
const total = judged.reduce((sum, j) => sum + j.points.length, 0);
|
|
42
|
+
const lines = ['', `Judges (${judged.length} round(s), ${total} human point(s))`];
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const caught = judged.reduce((sum, j) => sum + (j.caught[name]?.length ?? 0), 0);
|
|
45
|
+
const only = judged.reduce((sum, j) => sum + (j.caught[name] ?? []).filter(id => j.judges.filter(o => o !== name).every(o => !(j.caught[o] ?? []).includes(id))).length, 0);
|
|
46
|
+
lines.push(` ${name}: raised ${caught}/${total}; ${only} that no other judge raised`);
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < names.length; i++) {
|
|
49
|
+
for (let k = i + 1; k < names.length; k++) {
|
|
50
|
+
const both = judged.filter(j => j.judges.includes(names[i]) && j.judges.includes(names[k]));
|
|
51
|
+
const a = both.flatMap(j => j.points.map(p => (j.caught[names[i]] ?? []).includes(p)));
|
|
52
|
+
const b = both.flatMap(j => j.points.map(p => (j.caught[names[k]] ?? []).includes(p)));
|
|
53
|
+
const kappa = cohensKappa(a, b);
|
|
54
|
+
if (a.length)
|
|
55
|
+
lines.push(` ${names[i]} and ${names[k]}: kappa ${Number.isNaN(kappa) ? 'n/a' : kappa.toFixed(2)} over ${a.length} point(s)${kappa > 0.8 ? ' (they share blind spots: a second judge adds little)' : ''}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const cost = judged.reduce((sum, j) => sum + (j.costUsd ?? 0), 0);
|
|
59
|
+
const runs = judged.reduce((sum, j) => sum + j.runs, 0);
|
|
60
|
+
const caughtPoints = rounds.filter(r => r.judges).reduce((sum, r) => sum + r.points.filter(p => p.caught).length, 0);
|
|
61
|
+
lines.push(` ${runs} agent run(s)${cost ? `, $${cost.toFixed(2)} recorded` : ''}${cost && caughtPoints ? `, $${(cost / caughtPoints).toFixed(2)} per caught point` : ''}`);
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatJudges, judgedFrom } from './backtest-judges.js';
|
|
3
|
+
const base = { outcome: 'findings', items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reviewers: ['claude', 'codex'], cached: false };
|
|
4
|
+
describe('measuring each judge', () => {
|
|
5
|
+
it('computes Cohen\'s kappa between two judges: 1 in full agreement, 0 at chance, and says when they share blind spots', () => {
|
|
6
|
+
const round = (claude, codex) => ({ judges: { judges: ['claude', 'codex'], caught: { claude, codex }, points: ['p1', 'p2', 'p3', 'p4'], runs: 2 }, points: [] });
|
|
7
|
+
expect(formatJudges([round(['p1', 'p3'], ['p1', 'p3'])])).toContain('kappa 1.00 over 4 point(s) (they share blind spots: a second judge adds little)');
|
|
8
|
+
expect(formatJudges([round(['p1', 'p2'], ['p1', 'p3'])])).toContain('kappa 0.00 over 4 point(s)');
|
|
9
|
+
expect(formatJudges([round(['p1'], ['p2'])])).toMatch(/kappa -0\.\d\d over 4/);
|
|
10
|
+
});
|
|
11
|
+
it('takes each judge\'s own item from the panel, including what it raised in its own words', () => {
|
|
12
|
+
const lock = { id: 'a', kind: 'finding', class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' };
|
|
13
|
+
const judged = judgedFrom({ ...base, costUsd: 2, runs: 3, panel: [
|
|
14
|
+
{ item: lock, judges: ['claude', 'codex'], calls: { claude: 'raised', codex: 'raised' }, status: 'confirmed', members: [{ judge: 'claude', item: lock }, { judge: 'codex', item: { ...lock, id: 'b', line: 3, issue: 'lock taken late' } }] },
|
|
15
|
+
] });
|
|
16
|
+
expect(judged).toEqual({ judges: ['claude', 'codex'], costUsd: 2, runs: 3, raised: [
|
|
17
|
+
{ judge: 'claude', file: 'src/job.ts', line: 2, text: 'returns before the lock' },
|
|
18
|
+
{ judge: 'codex', file: 'src/job.ts', line: 3, text: 'lock taken late' },
|
|
19
|
+
] });
|
|
20
|
+
expect(judgedFrom({ ...base, reviewers: ['claude'] })).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('reports each judge, what only it found, each pair\'s agreement and the cost of a caught point', () => {
|
|
23
|
+
const round = (caught) => ({ judges: { judges: ['claude', 'codex'], caught, points: ['p1', 'p2', 'p3', 'p4'], runs: 3, costUsd: 3 }, points: ['p1', 'p2', 'p3', 'p4'].map(id => ({ id, caught: Object.values(caught).some(ids => ids.includes(id)) })) });
|
|
24
|
+
const report = formatJudges([round({ claude: ['p1', 'p2'], codex: ['p1', 'p3'] }), round({ claude: ['p1'], codex: ['p1'] })]);
|
|
25
|
+
expect(report).toContain('Judges (2 round(s), 8 human point(s))');
|
|
26
|
+
expect(report).toContain('claude: raised 3/8; 1 that no other judge raised');
|
|
27
|
+
expect(report).toContain('codex: raised 3/8; 1 that no other judge raised');
|
|
28
|
+
expect(report).toMatch(/claude and codex: kappa 0\.\d\d over 8 point\(s\)/);
|
|
29
|
+
expect(report).toContain('6 agent run(s), $6.00 recorded, $1.50 per caught point');
|
|
30
|
+
expect(formatJudges([{ points: [] }])).toBe('');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Config } from '../types/index.js';
|
|
3
3
|
import { type Exec, type Progress } from './reviewer.js';
|
|
4
|
+
import { type JudgeCatches, type JudgeRun } from './backtest-judges.js';
|
|
4
5
|
declare const Point: z.ZodObject<{
|
|
5
6
|
/** A regular expression over the finding's file path. */
|
|
6
7
|
file: z.ZodString;
|
|
@@ -292,6 +293,8 @@ export interface RoundResult {
|
|
|
292
293
|
durationMs: number;
|
|
293
294
|
/** Why the reviewer gave no verdict, when it ran. */
|
|
294
295
|
reviewerError?: string;
|
|
296
|
+
/** With two or more judges: the ledger points each raised on its own, and the round's runs and cost. */
|
|
297
|
+
judges?: JudgeCatches;
|
|
295
298
|
}
|
|
296
299
|
export interface BacktestOptions {
|
|
297
300
|
round?: string;
|
|
@@ -299,10 +302,7 @@ export interface BacktestOptions {
|
|
|
299
302
|
exec?: Exec;
|
|
300
303
|
progress?: Progress;
|
|
301
304
|
/** Produces the findings for a worktree; tests replace it. */
|
|
302
|
-
collect?: (worktree: string, round: LedgerRound, config: Config) => Promise<
|
|
303
|
-
items: BacktestItem[];
|
|
304
|
-
reviewerError?: string;
|
|
305
|
-
}>;
|
|
305
|
+
collect?: (worktree: string, round: LedgerRound, config: Config) => Promise<Collected>;
|
|
306
306
|
}
|
|
307
307
|
export declare function loadLedger(cwd: string): Ledger;
|
|
308
308
|
/** Rows that cannot match anything: no file pattern, neither a line window nor a text pattern, or a scaffold mark. */
|
|
@@ -311,5 +311,10 @@ export declare function runBacktest(cwd: string, config: Config, ledger: Ledger,
|
|
|
311
311
|
/** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
|
|
312
312
|
export declare function backtestPassed(results: RoundResult[]): boolean;
|
|
313
313
|
export declare function score(round: LedgerRound, head: string, items: BacktestItem[], durationMs: number, reviewerError: string | undefined): RoundResult;
|
|
314
|
+
interface Collected {
|
|
315
|
+
items: BacktestItem[];
|
|
316
|
+
reviewerError?: string;
|
|
317
|
+
judged?: JudgeRun;
|
|
318
|
+
}
|
|
314
319
|
export declare function formatBacktest(results: RoundResult[]): string;
|
|
315
320
|
export {};
|
package/dist/review/backtest.js
CHANGED
|
@@ -15,6 +15,8 @@ import path from 'path';
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { reviewChange } from './review.js';
|
|
17
17
|
import { defaultExec, runReviewer } from './reviewer.js';
|
|
18
|
+
import { reviewerInputs } from './reviewer/context.js';
|
|
19
|
+
import { formatJudges, judgedFrom } from './backtest-judges.js';
|
|
18
20
|
const Match = z.object({
|
|
19
21
|
/** A regular expression over the finding's file path. */
|
|
20
22
|
file: z.string(),
|
|
@@ -90,8 +92,8 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
|
90
92
|
const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
91
93
|
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
|
|
92
94
|
const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
|
|
93
|
-
const { items, reviewerError } = await collect(worktree, round, config);
|
|
94
|
-
const result = score(round, head, items, Date.now() - started, reviewerError);
|
|
95
|
+
const { items, reviewerError, judged } = await collect(worktree, round, config);
|
|
96
|
+
const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}) };
|
|
95
97
|
record(cwd, result);
|
|
96
98
|
results.push(result);
|
|
97
99
|
}
|
|
@@ -125,8 +127,10 @@ async function worktreeFor(cwd, commit, exec) {
|
|
|
125
127
|
if (!common)
|
|
126
128
|
throw new Error(`${cwd} is not a git repository`);
|
|
127
129
|
const resolved = await exec('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd, timeoutMs: GIT_TIMEOUT_MS });
|
|
128
|
-
if (resolved.exitCode !== 0)
|
|
129
|
-
|
|
130
|
+
if (resolved.exitCode !== 0) {
|
|
131
|
+
const reason = resolved.stderr.trim().split('\n').at(-1);
|
|
132
|
+
throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)${reason ? `: git said "${reason}"` : ''}`);
|
|
133
|
+
}
|
|
130
134
|
const dir = path.join(common, 'rigour-backtest', resolved.stdout.trim().slice(0, 12));
|
|
131
135
|
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
132
136
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
@@ -143,6 +147,11 @@ function shareDependencies(cwd, worktree) {
|
|
|
143
147
|
if (fs.existsSync(root) && !fs.existsSync(path.join(worktree, 'node_modules')))
|
|
144
148
|
fs.symlinkSync(root, path.join(worktree, 'node_modules'), 'junction');
|
|
145
149
|
}
|
|
150
|
+
/** Which ledger points each judge raised itself, matched the way the score matches any finding. */
|
|
151
|
+
function judgeCatches(round, judged) {
|
|
152
|
+
const caught = Object.fromEntries(judged.judges.map(judge => [judge, round.points.filter(point => judged.raised.some(r => r.judge === judge && matches(point, { gate: `judge:${judge}`, file: r.file, line: r.line, text: r.text, blocking: true }))).map(p => p.id)]));
|
|
153
|
+
return { judges: judged.judges, caught, points: round.points.map(p => p.id), runs: judged.runs, ...(judged.costUsd !== undefined ? { costUsd: judged.costUsd } : {}) };
|
|
154
|
+
}
|
|
146
155
|
async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
147
156
|
const review = await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: round.base }, typed: true });
|
|
148
157
|
const items = [
|
|
@@ -151,13 +160,14 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
|
151
160
|
];
|
|
152
161
|
if (!reviewer)
|
|
153
162
|
return { items };
|
|
154
|
-
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true,
|
|
163
|
+
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true, ...reviewerInputs(review) });
|
|
155
164
|
if (result.outcome === 'unavailable' || result.outcome === 'skipped')
|
|
156
165
|
return { items, reviewerError: result.reason ?? result.outcome };
|
|
157
166
|
// The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
|
|
158
|
-
const asReviewerItem = (item, blocking) => ({ gate: `reviewer:${item.reviewer ?? result.reviewers[0]}`, file: item.file ?? '', line: item.line, text: [item.issue, item.evidence].filter(Boolean).join(' '), blocking });
|
|
159
|
-
items.push(...result.items.map(i => asReviewerItem(i, true)), ...result.unverified.map(i => asReviewerItem(i, false)));
|
|
160
|
-
|
|
167
|
+
const asReviewerItem = (item, blocking) => ({ gate: `reviewer:${item.reviewer ?? result.reviewers[0]}`, file: item.file ?? '', line: item.line, text: [item.issue, item.consequence, item.evidence].filter(Boolean).join(' '), blocking });
|
|
168
|
+
items.push(...result.items.map(i => asReviewerItem(i, true)), ...[...result.unverified, ...result.notes, ...result.disputed].map(i => asReviewerItem(i, false)));
|
|
169
|
+
const judged = judgedFrom(result);
|
|
170
|
+
return { items, ...(judged ? { judged } : {}) };
|
|
161
171
|
}
|
|
162
172
|
function asItem(failure, blocking) {
|
|
163
173
|
return { gate: failure.id, file: failure.files?.[0] ?? '', line: failure.line, text: [failure.title, failure.details, failure.hint].filter(Boolean).join(' '), blocking };
|
|
@@ -185,7 +195,7 @@ export function formatBacktest(results) {
|
|
|
185
195
|
if (r.reviewerError)
|
|
186
196
|
lines.push(` NO VERDICT ${r.reviewerError}`);
|
|
187
197
|
}
|
|
188
|
-
return lines.join('\n');
|
|
198
|
+
return lines.join('\n') + formatJudges(results);
|
|
189
199
|
}
|
|
190
200
|
/** Which gate (or the reviewer) caught what: `; caught by unused-export 2, reviewer 1`, so a rule is judged on its own rows. */
|
|
191
201
|
function byGate(result) {
|
|
@@ -118,12 +118,12 @@ describe('rigour backtest on a repository', () => {
|
|
|
118
118
|
// The worktree is reused on the next run.
|
|
119
119
|
const again = await runBacktest(repo, config, loadLedger(repo), { round: 'r1', progress: () => undefined });
|
|
120
120
|
expect(again[0].points[0].caught).toBe(true);
|
|
121
|
-
});
|
|
121
|
+
}, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
|
|
122
122
|
it('names a round or a commit it cannot find', async () => {
|
|
123
123
|
const config = ConfigSchema.parse({ version: 1 });
|
|
124
124
|
const ledger = { rounds: [{ id: 'r1', commit: '0000000000000000000000000000000000000000', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] }] };
|
|
125
125
|
await expect(runBacktest(repo, config, ledger, { round: 'r9' })).rejects.toThrow('no round "r9"');
|
|
126
126
|
await expect(runBacktest(repo, config, ledger, { progress: () => undefined })).rejects.toThrow('is not in this repository');
|
|
127
127
|
expect(() => loadLedger(repo)).toThrow('no ledger');
|
|
128
|
-
});
|
|
128
|
+
}, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
|
|
129
129
|
});
|
package/dist/review/baseline.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function baseFindings(cwd, config, commit, files) {
|
|
|
39
39
|
const modules = path.join(cwd, 'node_modules');
|
|
40
40
|
if (fs.existsSync(modules))
|
|
41
41
|
fs.symlinkSync(modules, path.join(dir, 'node_modules'), 'junction');
|
|
42
|
-
|
|
42
|
+
// The team's commands find nothing to compare per finding (they report no file), so the base copy never runs them.
|
|
43
|
+
const report = await new GateRunner({ ...config, commands: {} }).run(dir, await normalizeScopePatterns(dir, files), undefined, { record: false });
|
|
43
44
|
return { failures: report.failures, added };
|
|
44
45
|
}
|
|
45
46
|
finally {
|
|
@@ -51,6 +51,13 @@ describe('unused exports', () => {
|
|
|
51
51
|
expect(names).toContainEqual(['src/index.ts', 'Row']);
|
|
52
52
|
expect(names).not.toContainEqual(['src/index.ts', 'Used']);
|
|
53
53
|
});
|
|
54
|
+
it('never reports a type-test file as orphaned: tsd runs test-d/ and *.test-d.ts by itself', () => {
|
|
55
|
+
write('test-d/types.ts', "import { start } from '../src/app';\nstart();\n");
|
|
56
|
+
write('src/app.test-d.ts', "import { start } from './app';\nstart();\n");
|
|
57
|
+
const orphans = orphanFileFailures(repo, diffFromGit(repo), config()).map(f => f.files?.[0]);
|
|
58
|
+
expect(orphans).not.toContain('test-d/types.ts');
|
|
59
|
+
expect(orphans).not.toContain('src/app.test-d.ts');
|
|
60
|
+
});
|
|
54
61
|
it('skips what a framework calls by name, and names the team allows', () => {
|
|
55
62
|
write('src/routes/+page.server.ts', 'export const load = () => ({});\nexport const helper = 1;\n');
|
|
56
63
|
write('src/plugin.ts', 'export const register = () => {};\n');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Exec } from './reviewer/exec.js';
|
|
2
|
+
export interface FixScope {
|
|
3
|
+
pr: number;
|
|
4
|
+
review: {
|
|
5
|
+
id: number;
|
|
6
|
+
login: string;
|
|
7
|
+
submittedAt: string;
|
|
8
|
+
commit: string;
|
|
9
|
+
};
|
|
10
|
+
cited: string[];
|
|
11
|
+
changed: string[];
|
|
12
|
+
extra: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function fixScope(cwd: string, options: {
|
|
15
|
+
pr: number | undefined;
|
|
16
|
+
review: number | undefined;
|
|
17
|
+
githubAccount: string | undefined;
|
|
18
|
+
}, exec?: Exec): Promise<{
|
|
19
|
+
scope?: FixScope;
|
|
20
|
+
error?: string;
|
|
21
|
+
}>;
|