@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.
Files changed (133) hide show
  1. package/README.md +16 -92
  2. package/dist/environment.test.js +0 -4
  3. package/dist/gates/agent-team.d.ts +0 -2
  4. package/dist/gates/agent-team.js +0 -8
  5. package/dist/gates/checkpoint.d.ts +0 -1
  6. package/dist/gates/checkpoint.js +0 -1
  7. package/dist/gates/context.d.ts +0 -4
  8. package/dist/gates/context.js +7 -17
  9. package/dist/gates/deep-analysis.d.ts +0 -1
  10. package/dist/gates/deep-runner.js +0 -1
  11. package/dist/gates/frontend-secret-exposure.test.js +2 -2
  12. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  13. package/dist/gates/hallucinated-imports/index.js +2 -2
  14. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  15. package/dist/gates/hallucinated-imports.test.js +10 -0
  16. package/dist/gates/runner.js +3 -4
  17. package/dist/gates/test-quality-matchers.d.ts +0 -4
  18. package/dist/gates/test-quality-matchers.js +0 -7
  19. package/dist/gates/test-quality.d.ts +0 -2
  20. package/dist/gates/test-quality.js +1 -10
  21. package/dist/hooks/checker.js +1 -1
  22. package/dist/hooks/index.d.ts +1 -2
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/types.d.ts +0 -14
  25. package/dist/hooks/types.js +1 -15
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +6 -1
  28. package/dist/inference/cloud-provider.d.ts +2 -0
  29. package/dist/inference/cloud-provider.js +16 -6
  30. package/dist/inference/cloud-provider.test.js +21 -0
  31. package/dist/pattern-index/embeddings.d.ts +0 -5
  32. package/dist/pattern-index/embeddings.js +11 -4
  33. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  34. package/dist/pattern-index/semantic-runtime.js +46 -0
  35. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  36. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  37. package/dist/review/backtest-judges.d.ts +38 -0
  38. package/dist/review/backtest-judges.js +63 -0
  39. package/dist/review/backtest-judges.test.d.ts +1 -0
  40. package/dist/review/backtest-judges.test.js +32 -0
  41. package/dist/review/backtest.d.ts +9 -4
  42. package/dist/review/backtest.js +19 -9
  43. package/dist/review/backtest.test.js +2 -2
  44. package/dist/review/baseline.js +2 -1
  45. package/dist/review/dead-code.test.js +7 -0
  46. package/dist/review/fix-scope.d.ts +21 -0
  47. package/dist/review/fix-scope.js +94 -0
  48. package/dist/review/fix-scope.test.d.ts +1 -0
  49. package/dist/review/fix-scope.test.js +104 -0
  50. package/dist/review/lint-overlay.js +1 -1
  51. package/dist/review/orphan-files.js +3 -2
  52. package/dist/review/partial-wiring.js +5 -3
  53. package/dist/review/query-patterns.js +3 -2
  54. package/dist/review/review.js +2 -1
  55. package/dist/review/reviewer/adapters.d.ts +23 -7
  56. package/dist/review/reviewer/adapters.js +32 -7
  57. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  58. package/dist/review/reviewer/adapters.test.js +23 -0
  59. package/dist/review/reviewer/background.d.ts +8 -1
  60. package/dist/review/reviewer/background.js +34 -9
  61. package/dist/review/reviewer/background.test.js +16 -2
  62. package/dist/review/reviewer/consensus.d.ts +36 -0
  63. package/dist/review/reviewer/consensus.js +144 -0
  64. package/dist/review/reviewer/context.d.ts +75 -0
  65. package/dist/review/reviewer/context.js +140 -0
  66. package/dist/review/reviewer/context.test.d.ts +1 -0
  67. package/dist/review/reviewer/context.test.js +18 -0
  68. package/dist/review/reviewer/exec.js +6 -2
  69. package/dist/review/reviewer/exec.test.d.ts +1 -0
  70. package/dist/review/reviewer/exec.test.js +23 -0
  71. package/dist/review/reviewer/inputs.d.ts +2 -0
  72. package/dist/review/reviewer/inputs.js +5 -1
  73. package/dist/review/reviewer/panel.d.ts +67 -0
  74. package/dist/review/reviewer/panel.js +101 -0
  75. package/dist/review/reviewer/panel.test.d.ts +1 -0
  76. package/dist/review/reviewer/panel.test.js +110 -0
  77. package/dist/review/reviewer/prompt.d.ts +11 -0
  78. package/dist/review/reviewer/prompt.js +26 -4
  79. package/dist/review/reviewer/settings.d.ts +58 -0
  80. package/dist/review/reviewer/settings.js +152 -0
  81. package/dist/review/reviewer/settings.test.d.ts +1 -0
  82. package/dist/review/reviewer/settings.test.js +57 -0
  83. package/dist/review/reviewer/store.d.ts +25 -2
  84. package/dist/review/reviewer/store.js +43 -1
  85. package/dist/review/reviewer/usage.d.ts +2 -0
  86. package/dist/review/reviewer/usage.js +28 -0
  87. package/dist/review/reviewer/usage.test.d.ts +1 -0
  88. package/dist/review/reviewer/usage.test.js +14 -0
  89. package/dist/review/reviewer/verdict.d.ts +15 -1
  90. package/dist/review/reviewer/verdict.js +17 -7
  91. package/dist/review/reviewer.d.ts +45 -2
  92. package/dist/review/reviewer.js +186 -25
  93. package/dist/review/reviewer.test.js +127 -12
  94. package/dist/review/test-files.d.ts +5 -1
  95. package/dist/review/test-files.js +6 -2
  96. package/dist/review/toolchain.js +10 -3
  97. package/dist/review/toolchain.test.js +6 -3
  98. package/dist/review/typed/program.d.ts +0 -1
  99. package/dist/review/typed/program.js +3 -3
  100. package/dist/review/typed/redundancy.js +43 -4
  101. package/dist/review/typed/redundancy.test.js +58 -0
  102. package/dist/review/typed/schema-nullability.d.ts +6 -0
  103. package/dist/review/typed/schema-nullability.js +218 -0
  104. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  105. package/dist/review/typed/schema-nullability.test.js +63 -0
  106. package/dist/review/unused-exports.js +5 -6
  107. package/dist/semantic/learn/types.d.ts +2 -2
  108. package/dist/services/adaptive-thresholds.d.ts +2 -52
  109. package/dist/services/adaptive-thresholds.js +2 -147
  110. package/dist/services/adaptive-thresholds.test.js +1 -81
  111. package/dist/settings.d.ts +14 -6
  112. package/dist/settings.js +0 -12
  113. package/dist/storage/team-diagnostics.js +1 -1
  114. package/dist/storage/team-schema.d.ts +1 -1
  115. package/dist/storage/team-schema.js +2 -0
  116. package/dist/storage/team-store.js +26 -3
  117. package/dist/storage/team-sync-scope.test.js +35 -2
  118. package/dist/telemetry/telemetry.d.ts +5 -0
  119. package/dist/telemetry/telemetry.js +11 -1
  120. package/dist/templates/paradigms.js +0 -8
  121. package/dist/templates/presets.d.ts +0 -1
  122. package/dist/templates/presets.js +0 -23
  123. package/dist/templates/universal-config.js +8 -45
  124. package/dist/types/index.d.ts +108 -566
  125. package/dist/types/index.js +41 -66
  126. package/dist/types/index.test.d.ts +1 -0
  127. package/dist/types/index.test.js +33 -0
  128. package/dist/utils/command-line.d.ts +8 -0
  129. package/dist/utils/command-line.js +8 -0
  130. package/dist/utils/command-line.test.d.ts +1 -0
  131. package/dist/utils/command-line.test.js +8 -0
  132. package/dist/utils/profile.js +1 -1
  133. 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-opus-4-6',
5
- anthropic: 'claude-sonnet-4-6',
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
- this.modelName = options?.modelName || DEFAULT_MODELS[this.providerName] || 'gpt-4o-mini';
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
- this.client = new Anthropic({ apiKey: this.apiKey });
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.baseUrl || DEFAULT_BASE_URLS[this.providerName] || undefined;
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((this.baseUrl || '').replace(/^https?:\/\//, ''));
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,3 @@
1
- /**
2
- * Semantic Embedding Service
3
- *
4
- * Uses Transformers.js for local vector embeddings.
5
- */
6
1
  /**
7
2
  * Generate an embedding for a piece of text.
8
3
  */
@@ -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
- // Dynamic import to isolate native dependency issues (like sharp)
30
- const { pipeline } = await import('@xenova/transformers');
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 {};
@@ -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
- throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)`);
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, hints: review.hints.join('\n') });
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
- return { items };
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
  });
@@ -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
- const report = await new GateRunner(config).run(dir, await normalizeScopePatterns(dir, files), undefined, { record: false });
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
+ }>;