@rigour-labs/core 6.4.4 → 6.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +16 -0
- package/dist/review/quiet.js +71 -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 +12 -1
- package/dist/review/review.js +16 -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/settings.js +4 -1
- package/dist/telemetry/telemetry.d.ts +28 -0
- package/dist/telemetry/telemetry.js +152 -0
- package/dist/telemetry/telemetry.test.d.ts +1 -0
- package/dist/telemetry/telemetry.test.js +59 -0
- package/dist/telemetry/token.d.ts +1 -0
- package/dist/telemetry/token.js +1 -0
- 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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const openaiCreate = vi.fn();
|
|
3
|
+
const anthropicCreate = vi.fn();
|
|
4
|
+
vi.mock('openai', () => ({
|
|
5
|
+
default: class {
|
|
6
|
+
chat = { completions: { create: openaiCreate } };
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@anthropic-ai/sdk', () => ({
|
|
10
|
+
default: class {
|
|
11
|
+
messages = { create: anthropicCreate };
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
const { CloudProvider } = await import('./cloud-provider.js');
|
|
15
|
+
const { priceTokens } = await import('./pricing.js');
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
openaiCreate.mockReset();
|
|
18
|
+
anthropicCreate.mockReset();
|
|
19
|
+
});
|
|
20
|
+
describe('CloudProvider', () => {
|
|
21
|
+
it('passes the per-call timeout to the SDK request', async () => {
|
|
22
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 10, output_tokens: 2 } });
|
|
23
|
+
const provider = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5' });
|
|
24
|
+
await provider.setup();
|
|
25
|
+
await provider.analyze('p', { timeout: 30_000 });
|
|
26
|
+
expect(anthropicCreate.mock.calls[0][1]).toEqual({ timeout: 30_000 });
|
|
27
|
+
});
|
|
28
|
+
it('prices tokens at list price when the provider reports no cost', async () => {
|
|
29
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1_000_000, output_tokens: 100_000 } });
|
|
30
|
+
const provider = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5' });
|
|
31
|
+
await provider.setup();
|
|
32
|
+
await provider.analyze('p');
|
|
33
|
+
expect(provider.usage()).toEqual({ inputTokens: 1_000_000, outputTokens: 100_000, costUsd: 3 });
|
|
34
|
+
});
|
|
35
|
+
it('asks OpenRouter for its own cost, and uses it', async () => {
|
|
36
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: 'ok' } }], usage: { prompt_tokens: 500, completion_tokens: 50, cost: 0.0123 } });
|
|
37
|
+
const provider = new CloudProvider('openrouter', 'k', { baseUrl: 'https://openrouter.ai/api/v1', modelName: 'anthropic/claude-sonnet-5.5' });
|
|
38
|
+
await provider.setup();
|
|
39
|
+
await provider.analyze('p');
|
|
40
|
+
expect(openaiCreate.mock.calls[0][0].usage).toEqual({ include: true });
|
|
41
|
+
expect(provider.usage()).toEqual({ inputTokens: 500, outputTokens: 50, costUsd: 0.0123 });
|
|
42
|
+
});
|
|
43
|
+
it('sends no OpenRouter-only field to other endpoints, and reports no cost for an unpriced model', async () => {
|
|
44
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: 'ok' } }], usage: { prompt_tokens: 500, completion_tokens: 50 } });
|
|
45
|
+
const provider = new CloudProvider('openai', 'k', { modelName: 'some-new-model' });
|
|
46
|
+
await provider.setup();
|
|
47
|
+
await provider.analyze('p');
|
|
48
|
+
expect(openaiCreate.mock.calls[0][0]).not.toHaveProperty('usage');
|
|
49
|
+
expect(provider.usage()).toEqual({ inputTokens: 500, outputTokens: 50 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('CloudProvider.chat', () => {
|
|
53
|
+
const history = [
|
|
54
|
+
{ role: 'user', content: 'review' },
|
|
55
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'a', name: 'read_file', arguments: { path: 'x.ts' } }, { id: 'b', name: 'grep', arguments: { pattern: 'y' } }] },
|
|
56
|
+
{ role: 'tool', toolCallId: 'a', content: 'x' },
|
|
57
|
+
{ role: 'tool', toolCallId: 'b', content: 'y' },
|
|
58
|
+
];
|
|
59
|
+
const tools = [{ name: 'read_file', description: 'd', parameters: { type: 'object' } }];
|
|
60
|
+
it('speaks Anthropic tool use: grouped tool results, tool_use blocks back', async () => {
|
|
61
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: 'checking' }, { type: 'tool_use', id: 'c', name: 'read_file', input: { path: 'z.ts' } }] });
|
|
62
|
+
const provider = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5' });
|
|
63
|
+
await provider.setup();
|
|
64
|
+
const reply = await provider.chat(history, tools);
|
|
65
|
+
const sent = anthropicCreate.mock.calls[0][0];
|
|
66
|
+
expect(sent.messages).toHaveLength(3);
|
|
67
|
+
expect(sent.messages[2].content.map((b) => b.tool_use_id)).toEqual(['a', 'b']);
|
|
68
|
+
expect(sent.tools[0]).toEqual({ name: 'read_file', description: 'd', input_schema: { type: 'object' } });
|
|
69
|
+
expect(reply).toEqual({ text: 'checking', toolCalls: [{ id: 'c', name: 'read_file', arguments: { path: 'z.ts' } }] });
|
|
70
|
+
expect(sent).not.toHaveProperty('tool_choice');
|
|
71
|
+
});
|
|
72
|
+
it('keeps tools defined but forbids calls when asked for a final answer', async () => {
|
|
73
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: '{}' }] });
|
|
74
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: '{}' } }] });
|
|
75
|
+
for (const name of ['claude', 'openai']) {
|
|
76
|
+
const provider = new CloudProvider(name, 'k', { modelName: 'm' });
|
|
77
|
+
await provider.setup();
|
|
78
|
+
await provider.chat(history, tools, { toolChoice: 'none' });
|
|
79
|
+
}
|
|
80
|
+
expect(anthropicCreate.mock.calls[0][0]).toMatchObject({ tools: [expect.anything()], tool_choice: { type: 'none' } });
|
|
81
|
+
expect(openaiCreate.mock.calls[0][0]).toMatchObject({ tools: [expect.anything()], tool_choice: 'none' });
|
|
82
|
+
});
|
|
83
|
+
it('speaks OpenAI function calling, and survives malformed arguments', async () => {
|
|
84
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: null, tool_calls: [{ id: 'c', function: { name: 'grep', arguments: '{not json' } }] } }] });
|
|
85
|
+
const provider = new CloudProvider('openai', 'k', { modelName: 'm' });
|
|
86
|
+
await provider.setup();
|
|
87
|
+
const reply = await provider.chat(history, tools);
|
|
88
|
+
const sent = openaiCreate.mock.calls[0][0];
|
|
89
|
+
expect(sent.messages[1].tool_calls[0].function).toEqual({ name: 'read_file', arguments: '{"path":"x.ts"}' });
|
|
90
|
+
expect(sent.messages[2]).toEqual({ role: 'tool', tool_call_id: 'a', content: 'x' });
|
|
91
|
+
expect(sent.tools[0].type).toBe('function');
|
|
92
|
+
expect(reply).toEqual({ text: '', toolCalls: [{ id: 'c', name: 'grep', arguments: {} }] });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('prompt caching', () => {
|
|
96
|
+
it('caches the first prompt for Claude, directly and through OpenRouter, and nowhere else', async () => {
|
|
97
|
+
anthropicCreate.mockResolvedValue({ content: [{ type: 'text', text: '{}' }] });
|
|
98
|
+
openaiCreate.mockResolvedValue({ choices: [{ message: { content: '{}' } }] });
|
|
99
|
+
const first = [{ role: 'user', content: 'big diff' }];
|
|
100
|
+
const claude = new CloudProvider('claude', 'k', { modelName: 'claude-sonnet-5-5' });
|
|
101
|
+
const viaOpenRouter = new CloudProvider('openrouter', 'k', { baseUrl: 'https://openrouter.ai/api/v1', modelName: 'anthropic/claude-sonnet-5.5' });
|
|
102
|
+
const other = new CloudProvider('openrouter', 'k', { baseUrl: 'https://openrouter.ai/api/v1', modelName: 'openai/gpt-5' });
|
|
103
|
+
for (const p of [claude, viaOpenRouter, other]) {
|
|
104
|
+
await p.setup();
|
|
105
|
+
await p.chat(first, []);
|
|
106
|
+
}
|
|
107
|
+
const cached = { role: 'user', content: [{ type: 'text', text: 'big diff', cache_control: { type: 'ephemeral' } }] };
|
|
108
|
+
expect(anthropicCreate.mock.calls[0][0].messages[0]).toEqual(cached);
|
|
109
|
+
expect(openaiCreate.mock.calls[0][0].messages[0]).toEqual(cached);
|
|
110
|
+
expect(openaiCreate.mock.calls[1][0].messages[0]).toEqual({ role: 'user', content: 'big diff' });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('priceTokens', () => {
|
|
114
|
+
it('prices known models and refuses to guess for the rest', () => {
|
|
115
|
+
expect(priceTokens('anthropic/claude-opus-5.5', 1_000_000, 1_000_000)).toBe(24);
|
|
116
|
+
expect(priceTokens('mystery-7b', 1, 1)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List prices for pricing a run's tokens when the provider does not report
|
|
3
|
+
* cost itself (OpenRouter does; see cloud-provider.ts). USD per million tokens.
|
|
4
|
+
*
|
|
5
|
+
* Only models whose price we have checked are listed. An unlisted model gets
|
|
6
|
+
* no cost at all rather than a guess: a wrong number in Studio is worse than
|
|
7
|
+
* a missing one.
|
|
8
|
+
*/
|
|
9
|
+
interface Price {
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function listPrice(model: string): Price | undefined;
|
|
14
|
+
export declare function priceTokens(model: string, inputTokens: number, outputTokens: number): number | undefined;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const PRICES = [
|
|
2
|
+
[/claude-opus-5[.-]5/, { input: 4, output: 20 }],
|
|
3
|
+
[/claude-sonnet-5[.-]5/, { input: 2, output: 10 }],
|
|
4
|
+
[/claude-haiku-4[.-]5/, { input: 1, output: 5 }],
|
|
5
|
+
];
|
|
6
|
+
export function listPrice(model) {
|
|
7
|
+
const name = model.toLowerCase();
|
|
8
|
+
return PRICES.find(([pattern]) => pattern.test(name))?.[1];
|
|
9
|
+
}
|
|
10
|
+
export function priceTokens(model, inputTokens, outputTokens) {
|
|
11
|
+
const price = listPrice(model);
|
|
12
|
+
return price ? (inputTokens * price.input + outputTokens * price.output) / 1_000_000 : undefined;
|
|
13
|
+
}
|
|
@@ -21,9 +21,49 @@ export interface InferenceProvider {
|
|
|
21
21
|
* Provider handles tokenization, temperature, etc.
|
|
22
22
|
*/
|
|
23
23
|
analyze(prompt: string, options?: InferenceOptions): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Multi-turn conversation with tools the model may call (cloud providers).
|
|
26
|
+
* The caller runs the tools and sends their results back as `tool` messages.
|
|
27
|
+
*/
|
|
28
|
+
chat?(messages: ChatMessage[], tools: ChatTool[], options?: InferenceOptions): Promise<ChatReply>;
|
|
29
|
+
/** Tokens (and cost, when known) used since setup; cloud providers only. */
|
|
30
|
+
usage?(): InferenceUsage;
|
|
24
31
|
/** Clean up resources (kill process, close connection) */
|
|
25
32
|
dispose(): void;
|
|
26
33
|
}
|
|
34
|
+
export interface ChatTool {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
/** JSON schema of the tool's arguments. */
|
|
38
|
+
parameters: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export interface ToolCall {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
arguments: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export type ChatMessage = {
|
|
46
|
+
role: 'user';
|
|
47
|
+
content: string;
|
|
48
|
+
} | {
|
|
49
|
+
role: 'assistant';
|
|
50
|
+
content: string;
|
|
51
|
+
toolCalls?: ToolCall[];
|
|
52
|
+
} | {
|
|
53
|
+
role: 'tool';
|
|
54
|
+
toolCallId: string;
|
|
55
|
+
content: string;
|
|
56
|
+
};
|
|
57
|
+
export interface ChatReply {
|
|
58
|
+
text: string;
|
|
59
|
+
toolCalls: ToolCall[];
|
|
60
|
+
}
|
|
61
|
+
export interface InferenceUsage {
|
|
62
|
+
inputTokens: number;
|
|
63
|
+
outputTokens: number;
|
|
64
|
+
/** Provider-reported or priced cost in USD; undefined when the model is not priced. */
|
|
65
|
+
costUsd?: number;
|
|
66
|
+
}
|
|
27
67
|
export interface InferenceOptions {
|
|
28
68
|
maxTokens?: number;
|
|
29
69
|
temperature?: number;
|
|
@@ -31,6 +71,8 @@ export interface InferenceOptions {
|
|
|
31
71
|
jsonMode?: boolean;
|
|
32
72
|
/** Constrain local output to this JSON schema instead of the findings schema. */
|
|
33
73
|
jsonSchema?: Record<string, unknown>;
|
|
74
|
+
/** chat(): 'none' forbids tool calls while keeping the tools defined (a history with tool calls needs them). */
|
|
75
|
+
toolChoice?: 'auto' | 'none';
|
|
34
76
|
}
|
|
35
77
|
/**
|
|
36
78
|
* A single finding from deep LLM analysis.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type LineSpan = [number, number];
|
|
2
|
+
export declare function changedFunctionSpans(cwd: string, changedLines: Record<string, Set<number>>): Record<string, LineSpan[]>;
|
|
3
|
+
/** The changed line nearest to `line` inside the changed function that contains it, if any. */
|
|
4
|
+
export declare function anchorInChangedFunction(line: number, lines: Set<number> | undefined, spans: LineSpan[] | undefined): number | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line spans of the functions a change touched, per file.
|
|
3
|
+
*
|
|
4
|
+
* A model often reports a defect where it starts (a missing guard above the
|
|
5
|
+
* edit, the declaration of a value the edit misuses) rather than on the edited
|
|
6
|
+
* line. Inside a changed function, that finding is still about the change.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { changedFunctions } from '../deep/changed-functions.js';
|
|
12
|
+
const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
13
|
+
export function changedFunctionSpans(cwd, changedLines) {
|
|
14
|
+
const spans = {};
|
|
15
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
16
|
+
if (!PARSEABLE.test(file) || lines.size === 0)
|
|
17
|
+
continue;
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false);
|
|
26
|
+
const found = changedFunctions(sourceFile, [...lines]).map((fn) => [
|
|
27
|
+
sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1,
|
|
28
|
+
sourceFile.getLineAndCharacterOfPosition(fn.getEnd()).line + 1,
|
|
29
|
+
]);
|
|
30
|
+
if (found.length > 0)
|
|
31
|
+
spans[file] = found;
|
|
32
|
+
}
|
|
33
|
+
return spans;
|
|
34
|
+
}
|
|
35
|
+
/** The changed line nearest to `line` inside the changed function that contains it, if any. */
|
|
36
|
+
export function anchorInChangedFunction(line, lines, spans) {
|
|
37
|
+
const span = spans?.find(([start, end]) => line >= start && line <= end);
|
|
38
|
+
if (!span || !lines)
|
|
39
|
+
return undefined;
|
|
40
|
+
let best;
|
|
41
|
+
for (const changed of lines) {
|
|
42
|
+
if (changed < span[0] || changed > span[1])
|
|
43
|
+
continue;
|
|
44
|
+
if (best === undefined || Math.abs(changed - line) < Math.abs(best - line))
|
|
45
|
+
best = changed;
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which findings belong to a change.
|
|
3
3
|
*
|
|
4
|
-
* - findings: on a changed line; these decide PASS/FAIL.
|
|
4
|
+
* - findings: on a changed line; these decide PASS/FAIL. A deep finding on an
|
|
5
|
+
* unchanged line inside a changed function also belongs here, with
|
|
6
|
+
* `anchorLine` set to the nearest changed line (where a PR comment can go).
|
|
5
7
|
* - fileFindings: about a changed file as a whole (no line); shown as context.
|
|
8
|
+
* - contextFindings: deep findings elsewhere in a changed file; returned so
|
|
9
|
+
* nothing the model found disappears, never blocking.
|
|
6
10
|
* - unlocated: no file at all; counted, never shown as a changed-line finding.
|
|
7
|
-
* - outside: on an unchanged line; counted.
|
|
11
|
+
* - outside: any other finding on an unchanged line; counted.
|
|
8
12
|
*
|
|
9
13
|
* The CLI, the MCP tool and the GitHub integration all use this, so the same
|
|
10
14
|
* change gets the same verdict everywhere.
|
|
11
15
|
*/
|
|
12
16
|
import type { Failure } from '../types/index.js';
|
|
17
|
+
import { type LineSpan } from './changed-function-spans.js';
|
|
13
18
|
export interface ChangedLineSplit {
|
|
14
19
|
findings: Failure[];
|
|
15
20
|
fileFindings: Failure[];
|
|
21
|
+
contextFindings: Failure[];
|
|
16
22
|
unlocated: number;
|
|
17
23
|
outside: number;
|
|
18
24
|
}
|
|
19
|
-
export declare function splitByChangedLines(failures: Failure[], changedLines: Record<string, Set<number
|
|
25
|
+
export declare function splitByChangedLines(failures: Failure[], changedLines: Record<string, Set<number>>, spans?: Record<string, LineSpan[]>): ChangedLineSplit;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { anchorInChangedFunction } from './changed-function-spans.js';
|
|
2
|
+
export function splitByChangedLines(failures, changedLines, spans = {}) {
|
|
3
|
+
const split = { findings: [], fileFindings: [], contextFindings: [], unlocated: 0, outside: 0 };
|
|
3
4
|
for (const failure of failures) {
|
|
4
5
|
const files = failure.files ?? [];
|
|
5
6
|
if (files.length === 0) {
|
|
@@ -14,9 +15,25 @@ export function splitByChangedLines(failures, changedLines) {
|
|
|
14
15
|
else if (files.some(file => changedLines[file]?.has(failure.line))) {
|
|
15
16
|
split.findings.push(failure);
|
|
16
17
|
}
|
|
18
|
+
else if (failure.provenance === 'deep-analysis') {
|
|
19
|
+
placeDeepFinding(failure, files, changedLines, spans, split);
|
|
20
|
+
}
|
|
17
21
|
else {
|
|
18
22
|
split.outside++;
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
return split;
|
|
22
26
|
}
|
|
27
|
+
function placeDeepFinding(failure, files, changedLines, spans, split) {
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const anchorLine = anchorInChangedFunction(failure.line, changedLines[file], spans[file]);
|
|
30
|
+
if (anchorLine !== undefined) {
|
|
31
|
+
split.findings.push({ ...failure, anchorLine });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (files.some(file => changedLines[file]))
|
|
36
|
+
split.contextFindings.push(failure);
|
|
37
|
+
else
|
|
38
|
+
split.outside++;
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Report } from '../types/index.js';
|
|
2
|
+
export type DeepRunStats = NonNullable<Report['stats']['deep']>;
|
|
3
|
+
export interface DeepRun extends DeepRunStats {
|
|
4
|
+
at: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DeepRunSummary {
|
|
7
|
+
runs: number;
|
|
8
|
+
/** Sum over runs that reported a cost; runs without one are counted in `unpricedRuns`. */
|
|
9
|
+
costUsd: number;
|
|
10
|
+
unpricedRuns: number;
|
|
11
|
+
inputTokens: number;
|
|
12
|
+
outputTokens: number;
|
|
13
|
+
functionsRanked: number;
|
|
14
|
+
functionsRouted: number;
|
|
15
|
+
/** Risky functions the model was not asked about because they were reviewed before. */
|
|
16
|
+
alreadyReviewed: number;
|
|
17
|
+
toolCalls: number;
|
|
18
|
+
findingsKept: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function appendDeepRun(cwd: string, stats: DeepRunStats, at?: string): void;
|
|
21
|
+
export declare function readDeepRuns(cwd: string): DeepRun[];
|
|
22
|
+
export declare function summarizeDeepRuns(runs: DeepRun[]): DeepRunSummary;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep runs, as they happened: model, tokens, cost, what the router sent and
|
|
3
|
+
* skipped. Every deep review appends one line (.rigour/deep-runs.jsonl), so
|
|
4
|
+
* Studio reports observed spend, never an estimate of what a run might cost.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
const LOG = path.join('.rigour', 'deep-runs.jsonl');
|
|
9
|
+
const MAX_LOG_BYTES = 1024 * 1024;
|
|
10
|
+
export function appendDeepRun(cwd, stats, at = new Date().toISOString()) {
|
|
11
|
+
if (!stats.enabled || stats.status === 'error')
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
const file = path.join(cwd, LOG);
|
|
15
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
16
|
+
fs.appendFileSync(file, JSON.stringify({ at, ...stats }) + '\n');
|
|
17
|
+
if (fs.statSync(file).size > MAX_LOG_BYTES) {
|
|
18
|
+
const lines = fs.readFileSync(file, 'utf8').trimEnd().split('\n');
|
|
19
|
+
fs.writeFileSync(file, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Recording spend must never fail a review.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function readDeepRuns(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readFileSync(path.join(cwd, LOG), 'utf8').split('\n').filter(Boolean)
|
|
29
|
+
.flatMap(line => { try {
|
|
30
|
+
return [JSON.parse(line)];
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
} });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function summarizeDeepRuns(runs) {
|
|
41
|
+
const summary = {
|
|
42
|
+
runs: runs.length, costUsd: 0, unpricedRuns: 0, inputTokens: 0, outputTokens: 0,
|
|
43
|
+
functionsRanked: 0, functionsRouted: 0, alreadyReviewed: 0, toolCalls: 0, findingsKept: 0,
|
|
44
|
+
};
|
|
45
|
+
for (const run of runs) {
|
|
46
|
+
if (typeof run.cost_usd === 'number')
|
|
47
|
+
summary.costUsd += run.cost_usd;
|
|
48
|
+
else if (run.tier === 'cloud')
|
|
49
|
+
summary.unpricedRuns++;
|
|
50
|
+
summary.inputTokens += run.input_tokens ?? 0;
|
|
51
|
+
summary.outputTokens += run.output_tokens ?? 0;
|
|
52
|
+
summary.functionsRanked += run.router?.functions ?? 0;
|
|
53
|
+
summary.functionsRouted += run.router?.routed ?? 0;
|
|
54
|
+
summary.alreadyReviewed += run.router?.already_reviewed ?? 0;
|
|
55
|
+
summary.toolCalls += run.tool_calls ?? 0;
|
|
56
|
+
summary.findingsKept += run.findings_count ?? 0;
|
|
57
|
+
}
|
|
58
|
+
return summary;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
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 { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './deep-runs.js';
|
|
6
|
+
let dir;
|
|
7
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deep-runs-')); });
|
|
8
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
9
|
+
describe('deep runs', () => {
|
|
10
|
+
it('records what each run spent and sums only what was reported', () => {
|
|
11
|
+
appendDeepRun(dir, { enabled: true, status: 'ok', tier: 'cloud', cost_usd: 0.25, input_tokens: 1000, output_tokens: 100,
|
|
12
|
+
router: { functions: 10, routed: 3, files_skipped: 2, already_reviewed: 1 }, tool_calls: 7, findings_count: 2 });
|
|
13
|
+
appendDeepRun(dir, { enabled: true, status: 'partial', tier: 'cloud', input_tokens: 500, output_tokens: 50 });
|
|
14
|
+
appendDeepRun(dir, { enabled: true, status: 'ok', tier: 'lite' });
|
|
15
|
+
appendDeepRun(dir, { enabled: true, status: 'error', tier: 'cloud', cost_usd: 9 }); // nothing ran: not a run
|
|
16
|
+
const runs = readDeepRuns(dir);
|
|
17
|
+
expect(runs).toHaveLength(3);
|
|
18
|
+
expect(summarizeDeepRuns(runs)).toEqual({
|
|
19
|
+
runs: 3, costUsd: 0.25, unpricedRuns: 1, inputTokens: 1500, outputTokens: 150,
|
|
20
|
+
functionsRanked: 10, functionsRouted: 3, alreadyReviewed: 1, toolCalls: 7, findingsKept: 2,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated files: code a tool wrote, not a change anyone authored.
|
|
3
|
+
*
|
|
4
|
+
* A finding in a generated client, schema dump or lockfile cannot be fixed
|
|
5
|
+
* where it is reported (the next codegen run overwrites it) and is noise in a
|
|
6
|
+
* review. They are recognised by name and by the marker generators put at the
|
|
7
|
+
* top of the file.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
const GENERATED_NAME = /(\.|-|_)(gen|generated|pb)\.[cm]?[jt]sx?$|\.d\.ts\.map$|(^|\/)(__generated__|generated|gen)\/|\.min\.[cm]?js$|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$/i;
|
|
12
|
+
const GENERATED_MARKER = /@generated\b|\bauto-?generated\b|\bDO NOT EDIT\b|\bThis file (?:is|was) (?:automatically )?generated\b/i;
|
|
13
|
+
const HEADER_BYTES = 1024;
|
|
14
|
+
export function isGeneratedFile(cwd, file) {
|
|
15
|
+
if (GENERATED_NAME.test(file))
|
|
16
|
+
return true;
|
|
17
|
+
let fd;
|
|
18
|
+
try {
|
|
19
|
+
fd = fs.openSync(path.join(cwd, file), 'r');
|
|
20
|
+
const buffer = Buffer.alloc(HEADER_BYTES);
|
|
21
|
+
const read = fs.readSync(fd, buffer, 0, HEADER_BYTES, 0);
|
|
22
|
+
return GENERATED_MARKER.test(buffer.subarray(0, read).toString('utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (fd !== undefined)
|
|
29
|
+
fs.closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The changed lines without generated files. */
|
|
33
|
+
export function withoutGenerated(cwd, changedLines) {
|
|
34
|
+
return Object.fromEntries(Object.entries(changedLines).filter(([file]) => !isGeneratedFile(cwd, file)));
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
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 { isGeneratedFile, withoutGenerated } from './generated-files.js';
|
|
6
|
+
let dir;
|
|
7
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-')); });
|
|
8
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
9
|
+
describe('generated files', () => {
|
|
10
|
+
it('recognises generated files by name and by the marker at their top', () => {
|
|
11
|
+
fs.writeFileSync(path.join(dir, 'client.ts'), '// This file is auto-generated by @hey-api/openapi-ts\nexport const x = 1;\n');
|
|
12
|
+
fs.writeFileSync(path.join(dir, 'schema.ts'), '/* eslint-disable */\n// @generated by protoc\n');
|
|
13
|
+
fs.writeFileSync(path.join(dir, 'handwritten.ts'), "// Generates the client; edit freely.\nexport const generate = () => 'x';\n");
|
|
14
|
+
expect(isGeneratedFile(dir, 'client.ts')).toBe(true);
|
|
15
|
+
expect(isGeneratedFile(dir, 'schema.ts')).toBe(true);
|
|
16
|
+
expect(isGeneratedFile(dir, 'src/api/client.gen.ts')).toBe(true);
|
|
17
|
+
expect(isGeneratedFile(dir, 'src/__generated__/types.ts')).toBe(true);
|
|
18
|
+
expect(isGeneratedFile(dir, 'handwritten.ts')).toBe(false);
|
|
19
|
+
expect(isGeneratedFile(dir, 'src/missing.ts')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it('drops generated files from the change under review', () => {
|
|
22
|
+
const changed = { 'src/a.ts': new Set([1]), 'src/b.gen.ts': new Set([2]) };
|
|
23
|
+
expect(Object.keys(withoutGenerated(dir, changed))).toEqual(['src/a.ts']);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const REVIEWED_FILE: string;
|
|
2
|
+
export type ReviewVerdict = 'fixed' | 'no_issue';
|
|
3
|
+
export interface LedgerEntry {
|
|
4
|
+
file: string;
|
|
5
|
+
function: string;
|
|
6
|
+
hash: string;
|
|
7
|
+
/** 'agent', 'human', or 'byok:<model>'. */
|
|
8
|
+
reviewer: string;
|
|
9
|
+
verdict: ReviewVerdict;
|
|
10
|
+
note: string;
|
|
11
|
+
at: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ReviewedKey {
|
|
14
|
+
file: string;
|
|
15
|
+
function: string;
|
|
16
|
+
hash: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function recordReview(cwd: string, entry: Omit<LedgerEntry, 'at'>, at?: string): LedgerEntry;
|
|
19
|
+
export declare function readLedger(cwd: string): LedgerEntry[];
|
|
20
|
+
/** True when this exact function text has a review on record (local log or a committed reviewed.json). */
|
|
21
|
+
export declare function isReviewed(reviewed: ReviewedKey[], key: ReviewedKey): boolean;
|
|
22
|
+
export declare function reviewedKeys(cwd: string): ReviewedKey[];
|
|
23
|
+
/** The shareable form: latest verdict per function hash, sorted, no code and no notes. */
|
|
24
|
+
export declare function exportReviewed(cwd: string): {
|
|
25
|
+
path: string;
|
|
26
|
+
entries: number;
|
|
27
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review ledger: which changed functions were reviewed before the PR, by whom,
|
|
3
|
+
* with what verdict.
|
|
4
|
+
*
|
|
5
|
+
* Entries are keyed by the function's content hash, so any edit after the
|
|
6
|
+
* review makes the entry stale: a ledger can say "this exact code was looked
|
|
7
|
+
* at", never "this function is fine forever". The local log is append-only
|
|
8
|
+
* (.rigour/review-ledger.jsonl); `exportReviewed` writes the shareable form
|
|
9
|
+
* (.rigour/reviewed.json: hashes and verdicts only, no code, no notes) that a
|
|
10
|
+
* team may commit so the PR bot skips what was already reviewed.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
const LOG = path.join('.rigour', 'review-ledger.jsonl');
|
|
15
|
+
export const REVIEWED_FILE = path.join('.rigour', 'reviewed.json');
|
|
16
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
export function recordReview(cwd, entry, at = new Date().toISOString()) {
|
|
18
|
+
const full = { ...entry, at };
|
|
19
|
+
const file = path.join(cwd, LOG);
|
|
20
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
21
|
+
fs.appendFileSync(file, JSON.stringify(full) + '\n');
|
|
22
|
+
if (fs.statSync(file).size > MAX_LOG_BYTES) {
|
|
23
|
+
const lines = fs.readFileSync(file, 'utf8').trimEnd().split('\n');
|
|
24
|
+
fs.writeFileSync(file, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n');
|
|
25
|
+
}
|
|
26
|
+
return full;
|
|
27
|
+
}
|
|
28
|
+
export function readLedger(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync(path.join(cwd, LOG), 'utf8').split('\n').filter(Boolean)
|
|
31
|
+
.flatMap(line => { try {
|
|
32
|
+
return [JSON.parse(line)];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
} });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** True when this exact function text has a review on record (local log or a committed reviewed.json). */
|
|
43
|
+
export function isReviewed(reviewed, key) {
|
|
44
|
+
return reviewed.some(r => r.file === key.file && r.function === key.function && r.hash === key.hash);
|
|
45
|
+
}
|
|
46
|
+
export function reviewedKeys(cwd) {
|
|
47
|
+
return [...readLedger(cwd), ...readReviewedFile(cwd)];
|
|
48
|
+
}
|
|
49
|
+
/** The shareable form: latest verdict per function hash, sorted, no code and no notes. */
|
|
50
|
+
export function exportReviewed(cwd) {
|
|
51
|
+
const latest = new Map();
|
|
52
|
+
for (const entry of readLedger(cwd))
|
|
53
|
+
latest.set(`${entry.file}\u0000${entry.function}\u0000${entry.hash}`, entry);
|
|
54
|
+
const entries = [...latest.values()]
|
|
55
|
+
.map(e => ({ file: e.file, function: e.function, hash: e.hash, reviewer: e.reviewer, verdict: e.verdict }))
|
|
56
|
+
.sort((a, b) => a.file.localeCompare(b.file) || a.function.localeCompare(b.function) || a.hash.localeCompare(b.hash));
|
|
57
|
+
const out = path.join(cwd, REVIEWED_FILE);
|
|
58
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
59
|
+
fs.writeFileSync(out, JSON.stringify({ version: 1, entries }, null, 2) + '\n');
|
|
60
|
+
return { path: REVIEWED_FILE, entries: entries.length };
|
|
61
|
+
}
|
|
62
|
+
function readReviewedFile(cwd) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(cwd, REVIEWED_FILE), 'utf8'));
|
|
65
|
+
return Array.isArray(parsed?.entries)
|
|
66
|
+
? parsed.entries.filter((e) => typeof e?.file === 'string' && typeof e?.function === 'string' && typeof e?.hash === 'string')
|
|
67
|
+
: [];
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Failure } from '../types/index.js';
|
|
2
|
+
export declare const DISMISSED_FILE: string;
|
|
3
|
+
export declare function isProven(failure: Failure): boolean;
|
|
4
|
+
/** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
|
|
5
|
+
export declare function findingKey(failure: Failure): string;
|
|
6
|
+
export interface QuietSplit {
|
|
7
|
+
speaking: Failure[];
|
|
8
|
+
advisory: Failure[];
|
|
9
|
+
dismissed: number;
|
|
10
|
+
/** Which gates the dismissed findings came from: where Rigour is wrong for this team. */
|
|
11
|
+
dismissedByGate: Record<string, number>;
|
|
12
|
+
}
|
|
13
|
+
export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean): QuietSplit;
|
|
14
|
+
export declare function dismissedKeys(cwd: string): Set<string>;
|
|
15
|
+
/** Record "not a bug" for a finding key; idempotent. */
|
|
16
|
+
export declare function dismissFinding(cwd: string, key: string, reason: string, at?: string): boolean;
|