@rigour-labs/core 6.5.3 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/context/automatic-index-cache.test.d.ts +1 -0
- package/dist/context/automatic-index-cache.test.js +30 -0
- package/dist/context/automatic-index.js +0 -4
- package/dist/context/cache-engine.d.ts +7 -38
- package/dist/context/cache-engine.js +7 -70
- package/dist/context/cache-engine.test.js +2 -46
- package/dist/context/index-bridge.d.ts +1 -7
- package/dist/context/index-bridge.js +1 -79
- package/dist/deep/risk.d.ts +6 -0
- package/dist/deep/risk.js +7 -1
- package/dist/deep/risk.test.js +12 -1
- package/dist/firewall/arbitration-token.d.ts +12 -3
- package/dist/firewall/arbitration-token.js +30 -23
- package/dist/firewall/attestation.d.ts +7 -2
- package/dist/firewall/attestation.js +49 -28
- package/dist/firewall/execution-receipt.d.ts +5 -0
- package/dist/firewall/execution-receipt.js +10 -1
- package/dist/firewall/trust-boundaries.test.d.ts +1 -0
- package/dist/firewall/trust-boundaries.test.js +64 -0
- package/dist/firewall/trusted-control.test.js +10 -0
- package/dist/gates/deep-analysis.d.ts +6 -0
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/trusted-reviews.test.d.ts +1 -0
- package/dist/gates/trusted-reviews.test.js +16 -0
- package/dist/hooks/checker.js +40 -0
- package/dist/hooks/checker.test.js +18 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/session-state.d.ts +8 -0
- package/dist/hooks/session-state.js +61 -0
- package/dist/hooks/stop-review.d.ts +6 -4
- package/dist/hooks/stop-review.js +5 -3
- package/dist/hooks/templates.js +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/review/agent-fixes.d.ts +25 -2
- package/dist/review/agent-fixes.js +27 -6
- package/dist/review/check-outcomes.d.ts +2 -1
- package/dist/review/check-outcomes.js +11 -2
- package/dist/review/effectiveness.d.ts +13 -0
- package/dist/review/git-diff.d.ts +3 -0
- package/dist/review/git-diff.js +5 -0
- package/dist/review/learning-events.d.ts +14 -0
- package/dist/review/learning-events.js +21 -0
- package/dist/review/migration-order.js +1 -1
- package/dist/review/quiet.d.ts +3 -2
- package/dist/review/quiet.js +10 -6
- package/dist/review/review.d.ts +4 -0
- package/dist/review/review.js +14 -2
- package/dist/review/review.test.js +19 -0
- package/dist/review/stories.d.ts +25 -0
- package/dist/review/stories.js +75 -0
- package/dist/review/stories.test.d.ts +1 -0
- package/dist/review/stories.test.js +58 -0
- package/dist/review/trusted-state.d.ts +6 -0
- package/dist/review/trusted-state.js +35 -0
- package/dist/storage/cache-cleanup.d.ts +19 -0
- package/dist/storage/cache-cleanup.js +90 -0
- package/dist/storage/cache-cleanup.test.d.ts +1 -0
- package/dist/storage/cache-cleanup.test.js +54 -0
- package/dist/storage/db.js +3 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/local-encryption.js +2 -2
- package/dist/storage/team-store.js +2 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/utils/user-state.d.ts +7 -0
- package/dist/utils/user-state.js +21 -0
- package/package.json +6 -6
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { Failure } from '../types/index.js';
|
|
2
|
+
import { type CatchStage } from './stories.js';
|
|
3
|
+
interface OpenFinding {
|
|
4
|
+
file: string;
|
|
5
|
+
rule: string;
|
|
6
|
+
/** What the finding said; absent in entries captured before it was kept. */
|
|
7
|
+
title?: string;
|
|
8
|
+
details?: string;
|
|
9
|
+
before: string;
|
|
10
|
+
openedAt: string;
|
|
11
|
+
/** The stage that first reported it; absent in entries captured before stages were kept. */
|
|
12
|
+
stage?: CatchStage;
|
|
13
|
+
/** Its dismissal key (quiet.ts): a finding someone dismissed was not fixed. */
|
|
14
|
+
key?: string;
|
|
15
|
+
}
|
|
2
16
|
export interface ResolvedFix {
|
|
3
17
|
id: string;
|
|
4
18
|
file: string;
|
|
@@ -8,6 +22,9 @@ export interface ResolvedFix {
|
|
|
8
22
|
before: string;
|
|
9
23
|
after: string;
|
|
10
24
|
resolvedAt: string;
|
|
25
|
+
stage?: CatchStage;
|
|
26
|
+
/** When the finding was first reported, for time to fix. */
|
|
27
|
+
openedAt?: string;
|
|
11
28
|
}
|
|
12
29
|
export interface FixCapture {
|
|
13
30
|
opened: number;
|
|
@@ -15,8 +32,14 @@ export interface FixCapture {
|
|
|
15
32
|
/** The fixes this review resolved, for turning into lessons (storage/fix-lessons.ts). */
|
|
16
33
|
fixes: ResolvedFix[];
|
|
17
34
|
}
|
|
18
|
-
/**
|
|
19
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Record a review's findings: open new ones, resolve open ones the review no longer reports.
|
|
37
|
+
* Each resolved fix is also kept as a story, credited to the stage that first reported it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[], stage?: CatchStage): FixCapture;
|
|
20
40
|
export declare function listResolvedFixes(cwd: string): ResolvedFix[];
|
|
21
41
|
export declare function removeResolvedFix(cwd: string, id: string): void;
|
|
22
42
|
export declare function openFindingCount(cwd: string): number;
|
|
43
|
+
/** Findings reported and not fixed yet, newest first: what still needs someone. */
|
|
44
|
+
export declare function listOpenFindings(cwd: string): Array<Omit<OpenFinding, 'before'>>;
|
|
45
|
+
export {};
|
|
@@ -14,24 +14,37 @@ import crypto from 'crypto';
|
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import { checkId, recordOutcome } from './check-outcomes.js';
|
|
17
|
+
import { appendStory, compactDiff } from './stories.js';
|
|
18
|
+
import { dismissedKeys, findingKey } from './quiet.js';
|
|
17
19
|
const DIR = path.join('.rigour', 'agent-fixes');
|
|
18
20
|
const MAX_FILE_BYTES = 200_000;
|
|
19
21
|
const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Record a review's findings: open new ones, resolve open ones the review no longer reports.
|
|
24
|
+
* Each resolved fix is also kept as a story, credited to the stage that first reported it.
|
|
25
|
+
*/
|
|
26
|
+
export function recordReviewOutcome(cwd, findings, reviewedFiles, stage = 'review') {
|
|
22
27
|
const open = readOpen(cwd);
|
|
23
28
|
const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
|
|
24
29
|
const fixes = resolveGone(cwd, open, current, new Set(reviewedFiles));
|
|
25
|
-
const
|
|
30
|
+
for (const fix of fixes) {
|
|
31
|
+
appendStory(cwd, { at: fix.resolvedAt, openedAt: fix.openedAt, stage: fix.stage ?? stage, file: fix.file, rule: fix.rule, title: fix.title ?? fix.rule, details: fix.details, diff: compactDiff(fix.before, fix.after) });
|
|
32
|
+
}
|
|
33
|
+
const opened = openNew(cwd, open, current, stage);
|
|
26
34
|
writeOpen(cwd, open);
|
|
27
35
|
return { opened, resolved: fixes.length, fixes };
|
|
28
36
|
}
|
|
29
37
|
/** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
|
|
30
38
|
function resolveGone(cwd, open, current, reviewed) {
|
|
31
39
|
const resolved = [];
|
|
40
|
+
const dismissed = dismissedKeys(cwd);
|
|
32
41
|
for (const [key, entry] of Object.entries(open)) {
|
|
33
42
|
if (current.has(key))
|
|
34
43
|
continue;
|
|
44
|
+
if (entry.key && dismissed.has(entry.key)) {
|
|
45
|
+
delete open[key]; // gone because a person dismissed it, not because anyone fixed it
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
35
48
|
if (!reviewed.has(entry.file)) {
|
|
36
49
|
if (Date.now() - Date.parse(entry.openedAt) > OPEN_TTL_MS)
|
|
37
50
|
delete open[key];
|
|
@@ -46,7 +59,7 @@ function resolveGone(cwd, open, current, reviewed) {
|
|
|
46
59
|
}
|
|
47
60
|
return resolved;
|
|
48
61
|
}
|
|
49
|
-
function openNew(cwd, open, current) {
|
|
62
|
+
function openNew(cwd, open, current, stage) {
|
|
50
63
|
let opened = 0;
|
|
51
64
|
for (const [key, finding] of current) {
|
|
52
65
|
if (open[key])
|
|
@@ -55,7 +68,7 @@ function openNew(cwd, open, current) {
|
|
|
55
68
|
const before = readSmall(cwd, file);
|
|
56
69
|
if (before === null)
|
|
57
70
|
continue;
|
|
58
|
-
open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString() };
|
|
71
|
+
open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString(), stage, key: findingKey(finding) };
|
|
59
72
|
opened++;
|
|
60
73
|
}
|
|
61
74
|
return opened;
|
|
@@ -79,6 +92,14 @@ export function removeResolvedFix(cwd, id) {
|
|
|
79
92
|
export function openFindingCount(cwd) {
|
|
80
93
|
return Object.keys(readOpen(cwd)).length;
|
|
81
94
|
}
|
|
95
|
+
/** Findings reported and not fixed yet, newest first: what still needs someone. */
|
|
96
|
+
export function listOpenFindings(cwd) {
|
|
97
|
+
const dismissed = dismissedKeys(cwd);
|
|
98
|
+
return Object.values(readOpen(cwd))
|
|
99
|
+
.filter(entry => !(entry.key && dismissed.has(entry.key)))
|
|
100
|
+
.map(({ before: _before, ...rest }) => rest)
|
|
101
|
+
.sort((a, b) => b.openedAt.localeCompare(a.openedAt));
|
|
102
|
+
}
|
|
82
103
|
function readOpen(cwd) {
|
|
83
104
|
try {
|
|
84
105
|
return JSON.parse(fs.readFileSync(path.join(cwd, DIR, 'open.json'), 'utf8'));
|
|
@@ -98,7 +119,7 @@ function writeOpen(cwd, open) {
|
|
|
98
119
|
}
|
|
99
120
|
function writeResolved(cwd, entry, after) {
|
|
100
121
|
const id = crypto.createHash('sha256').update(`${entry.rule}\0${entry.file}\0${entry.before}\0${after}`).digest('hex').slice(0, 16);
|
|
101
|
-
const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString() };
|
|
122
|
+
const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString(), ...(entry.stage ? { stage: entry.stage } : {}), openedAt: entry.openedAt };
|
|
102
123
|
try {
|
|
103
124
|
fs.mkdirSync(path.join(cwd, DIR, 'resolved'), { recursive: true });
|
|
104
125
|
fs.writeFileSync(path.join(cwd, DIR, 'resolved', `${id}.json`), JSON.stringify(fix));
|
|
@@ -19,7 +19,8 @@ export declare function checkId(finding: {
|
|
|
19
19
|
}): string;
|
|
20
20
|
export declare function precisionOf(outcome: CheckOutcome): number;
|
|
21
21
|
export declare function isMuted(outcome: CheckOutcome | undefined): boolean;
|
|
22
|
-
|
|
22
|
+
/** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
|
|
23
|
+
export declare function readOutcomes(cwd: string, ref?: string): Record<string, CheckOutcome>;
|
|
23
24
|
export declare function recordOutcome(cwd: string, check: string, kind: keyof CheckOutcome): void;
|
|
24
25
|
/** Every check with an outcome, most dismissed first. */
|
|
25
26
|
export declare function checkPrecisions(cwd: string): CheckPrecision[];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
|
+
import { readStateFile } from './trusted-state.js';
|
|
16
17
|
const OUTCOMES_FILE = path.join('.rigour', 'check-outcomes.json');
|
|
17
18
|
const REPORTED_FILE = path.join('.rigour', 'reported-findings.json');
|
|
18
19
|
const MAX_REPORTED = 2000;
|
|
@@ -32,8 +33,16 @@ export function isMuted(outcome) {
|
|
|
32
33
|
return false;
|
|
33
34
|
return outcome.fixed + outcome.dismissed >= MUTE_MIN_OUTCOMES && precisionOf(outcome) < MUTE_BELOW;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
/** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
|
|
37
|
+
export function readOutcomes(cwd, ref) {
|
|
38
|
+
if (!ref)
|
|
39
|
+
return readJson(path.join(cwd, OUTCOMES_FILE)) ?? {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readStateFile(cwd, OUTCOMES_FILE, ref) ?? '{}');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
37
46
|
}
|
|
38
47
|
export function recordOutcome(cwd, check, kind) {
|
|
39
48
|
const outcomes = readOutcomes(cwd);
|
|
@@ -11,6 +11,19 @@ export interface AgentEvent {
|
|
|
11
11
|
blocked?: boolean;
|
|
12
12
|
/** Findings that blocked a stop. */
|
|
13
13
|
blocking?: number;
|
|
14
|
+
/** lessons_served: where the agent got them, and their subjects (learning-events.ts). */
|
|
15
|
+
via?: string;
|
|
16
|
+
lessons?: string[];
|
|
17
|
+
/** pr_catches: what a branch review reported. */
|
|
18
|
+
findings?: Array<{
|
|
19
|
+
rule: string;
|
|
20
|
+
title: string;
|
|
21
|
+
file: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** reuse_suggested: what the agent was about to write, what already exists, and BLOCK or WARN. */
|
|
24
|
+
planned?: string;
|
|
25
|
+
existing?: string;
|
|
26
|
+
action?: string;
|
|
14
27
|
}
|
|
15
28
|
export interface ReviewEffectiveness {
|
|
16
29
|
toolCalls: Record<string, number>;
|
package/dist/review/git-diff.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* git does not track yet (what an agent has just written).
|
|
6
6
|
* - base: everything on this branch since it left `base` (the merge-base),
|
|
7
7
|
* including uncommitted changes (what a pull request contains).
|
|
8
|
+
* - since: everything after `commit`, committed or not, plus untracked files
|
|
9
|
+
* (what an agent session wrote, even if it committed along the way).
|
|
8
10
|
*/
|
|
9
11
|
import { spawnSync } from 'child_process';
|
|
10
12
|
export class GitDiffError extends Error {
|
|
@@ -14,6 +16,9 @@ export function diffFromGit(cwd, source = { mode: 'working' }) {
|
|
|
14
16
|
const mergeBase = git(cwd, ['merge-base', source.base, 'HEAD']).trim();
|
|
15
17
|
return git(cwd, ['diff', '--no-color', '--no-ext-diff', mergeBase]);
|
|
16
18
|
}
|
|
19
|
+
if (source.mode === 'since') {
|
|
20
|
+
return git(cwd, ['diff', '--no-color', '--no-ext-diff', source.commit]) + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
|
|
21
|
+
}
|
|
17
22
|
const tracked = hasHead(cwd) ? git(cwd, ['diff', '--no-color', '--no-ext-diff', 'HEAD']) : '';
|
|
18
23
|
return tracked + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
|
|
19
24
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two events the learning loop is measured by, written to the same local event log as every
|
|
3
|
+
* agent event: a lesson put in front of an agent, and findings a PR-scope review reported.
|
|
4
|
+
* Studio joins them with stories (stories.ts) to show each lesson's path from where it was
|
|
5
|
+
* learned to whether the same mistake still reaches a PR.
|
|
6
|
+
*/
|
|
7
|
+
import type { Failure } from '../types/index.js';
|
|
8
|
+
export type LessonChannel = 'recall' | 'context' | 'review';
|
|
9
|
+
/** Lessons an agent was given before or while writing code. Nothing is written when none were. */
|
|
10
|
+
export declare function recordLessonsServed(cwd: string, via: LessonChannel, subjects: string[]): void;
|
|
11
|
+
/** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
|
|
12
|
+
export declare function recordPrCatches(cwd: string, findings: Failure[]): void;
|
|
13
|
+
/** The agent was about to write something that already exists, and was pointed to it. */
|
|
14
|
+
export declare function recordReuseSuggested(cwd: string, planned: string, existing: string, action: 'BLOCK' | 'WARN'): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appendAgentEvent } from './effectiveness.js';
|
|
2
|
+
const MAX_LISTED = 20;
|
|
3
|
+
/** Lessons an agent was given before or while writing code. Nothing is written when none were. */
|
|
4
|
+
export function recordLessonsServed(cwd, via, subjects) {
|
|
5
|
+
const unique = [...new Set(subjects.filter(Boolean))].slice(0, MAX_LISTED);
|
|
6
|
+
if (unique.length)
|
|
7
|
+
appendAgentEvent(cwd, { type: 'lessons_served', via, lessons: unique });
|
|
8
|
+
}
|
|
9
|
+
/** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
|
|
10
|
+
export function recordPrCatches(cwd, findings) {
|
|
11
|
+
if (findings.length === 0)
|
|
12
|
+
return;
|
|
13
|
+
appendAgentEvent(cwd, {
|
|
14
|
+
type: 'pr_catches',
|
|
15
|
+
findings: findings.slice(0, MAX_LISTED).map(f => ({ rule: f.id, title: f.title, file: f.files?.[0] ?? '' })),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** The agent was about to write something that already exists, and was pointed to it. */
|
|
19
|
+
export function recordReuseSuggested(cwd, planned, existing, action) {
|
|
20
|
+
appendAgentEvent(cwd, { type: 'reuse_suggested', planned, existing, action });
|
|
21
|
+
}
|
|
@@ -18,7 +18,7 @@ export function migrationOrderFailures(cwd, diff, source, config) {
|
|
|
18
18
|
const settings = config.gates.migration_order;
|
|
19
19
|
if (!settings?.enabled)
|
|
20
20
|
return [];
|
|
21
|
-
const base = source?.mode === 'base' ? source.base : 'HEAD';
|
|
21
|
+
const base = source?.mode === 'base' ? source.base : source?.mode === 'since' ? source.commit : 'HEAD';
|
|
22
22
|
const byDirectory = new Map();
|
|
23
23
|
for (const file of addedFiles(diff)) {
|
|
24
24
|
const directory = path.posix.dirname(file);
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface QuietSplit {
|
|
|
12
12
|
/** Which gates the dismissed findings came from: where Rigour is wrong for this team. */
|
|
13
13
|
dismissedByGate: Record<string, number>;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
export declare function
|
|
15
|
+
/** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
|
|
16
|
+
export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean, trustedRef?: string): QuietSplit;
|
|
17
|
+
export declare function dismissedKeys(cwd: string, ref?: string): Set<string>;
|
|
17
18
|
/** Record "not a bug" for a finding key; idempotent. */
|
|
18
19
|
export declare function dismissFinding(cwd: string, key: string, reason: string, at?: string): boolean;
|
package/dist/review/quiet.js
CHANGED
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
* blocking. A team that wants them back sets review.include_heuristics.
|
|
12
12
|
*
|
|
13
13
|
* A finding a person judged "not a bug" is dismissed by its key and never
|
|
14
|
-
* reported again (.rigour/dismissed.json, meant to be committed).
|
|
14
|
+
* reported again (.rigour/dismissed.json, meant to be committed). An
|
|
15
|
+
* independent review reads it from the base (trusted-state.ts), so a change
|
|
16
|
+
* cannot dismiss its own findings.
|
|
15
17
|
*/
|
|
16
18
|
import crypto from 'crypto';
|
|
17
19
|
import fs from 'fs';
|
|
18
20
|
import path from 'path';
|
|
19
21
|
import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
|
|
22
|
+
import { readStateFile } from './trusted-state.js';
|
|
20
23
|
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
|
|
21
24
|
export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
|
|
22
25
|
export function isProven(failure) {
|
|
@@ -26,9 +29,10 @@ export function isProven(failure) {
|
|
|
26
29
|
export function findingKey(failure) {
|
|
27
30
|
return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
+
/** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
|
|
33
|
+
export function quietSplit(cwd, findings, includeHeuristics = false, trustedRef) {
|
|
34
|
+
const dismissed = dismissedKeys(cwd, trustedRef);
|
|
35
|
+
const outcomes = readOutcomes(cwd, trustedRef);
|
|
32
36
|
const split = { speaking: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {} };
|
|
33
37
|
for (const finding of findings) {
|
|
34
38
|
if (dismissed.has(findingKey(finding))) {
|
|
@@ -44,9 +48,9 @@ export function quietSplit(cwd, findings, includeHeuristics = false) {
|
|
|
44
48
|
}
|
|
45
49
|
return split;
|
|
46
50
|
}
|
|
47
|
-
export function dismissedKeys(cwd) {
|
|
51
|
+
export function dismissedKeys(cwd, ref) {
|
|
48
52
|
try {
|
|
49
|
-
const parsed = JSON.parse(
|
|
53
|
+
const parsed = JSON.parse(readStateFile(cwd, DISMISSED_FILE, ref) ?? '');
|
|
50
54
|
return new Set(Array.isArray(parsed?.entries) ? parsed.entries.map((e) => e?.key).filter((k) => typeof k === 'string') : []);
|
|
51
55
|
}
|
|
52
56
|
catch {
|
package/dist/review/review.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ReviewInput {
|
|
|
12
12
|
diffTests?: boolean;
|
|
13
13
|
/** Deep analysis; `focusLines` and `removedLines` are filled from the diff. */
|
|
14
14
|
deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
|
|
15
|
+
/** Read dismissals and check outcomes at this commit (the base), not as the change left them. */
|
|
16
|
+
trustedRef?: string;
|
|
15
17
|
}
|
|
16
18
|
export interface ReviewResult {
|
|
17
19
|
status: 'PASS' | 'FAIL' | 'ERROR';
|
|
@@ -33,6 +35,8 @@ export interface ReviewResult {
|
|
|
33
35
|
deepError?: string;
|
|
34
36
|
/** Gates that crashed instead of running; a proven one makes the result ERROR. */
|
|
35
37
|
gateErrors: string[];
|
|
38
|
+
/** rigour.yml or .rigour/ files this change edits: they steer the review, so they are called out. */
|
|
39
|
+
controlFilesChanged: string[];
|
|
36
40
|
}
|
|
37
41
|
export interface ReviewFinding {
|
|
38
42
|
id: string;
|
package/dist/review/review.js
CHANGED
|
@@ -19,12 +19,13 @@ import { checkId, rememberReported } from './check-outcomes.js';
|
|
|
19
19
|
import { diffFromGit } from './git-diff.js';
|
|
20
20
|
import { diffTestFailures } from './diff-test-findings.js';
|
|
21
21
|
import { migrationOrderFailures } from './migration-order.js';
|
|
22
|
+
import { isControlFile } from './trusted-state.js';
|
|
22
23
|
export async function reviewChange(input) {
|
|
23
24
|
const diff = input.diff ?? diffFromGit(input.cwd, input.source);
|
|
24
25
|
const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
|
|
25
26
|
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
26
27
|
if (targets.length === 0) {
|
|
27
|
-
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
|
|
28
|
+
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
|
|
28
29
|
}
|
|
29
30
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
30
31
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
@@ -33,7 +34,7 @@ export async function reviewChange(input) {
|
|
|
33
34
|
report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
|
|
34
35
|
const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
|
|
35
36
|
const deepError = deepAnalysisError(report);
|
|
36
|
-
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
|
|
37
|
+
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
|
|
37
38
|
rememberReported(input.cwd, [...quiet.speaking, ...quiet.advisory].map(f => ({ key: findingKey(f), check: checkId(f) })));
|
|
38
39
|
const gateErrors = crashedGates(report);
|
|
39
40
|
const provenCrashed = gateErrors.some(id => isProven({ id }));
|
|
@@ -51,9 +52,20 @@ export async function reviewChange(input) {
|
|
|
51
52
|
changedLines,
|
|
52
53
|
report,
|
|
53
54
|
gateErrors,
|
|
55
|
+
controlFilesChanged: controlFiles(diff),
|
|
54
56
|
...(deepError ? { deepError } : {}),
|
|
55
57
|
};
|
|
56
58
|
}
|
|
59
|
+
/** Every path the diff touches (including deletions, which parseDiff drops) that steers Rigour itself. */
|
|
60
|
+
function controlFiles(diff) {
|
|
61
|
+
const files = new Set();
|
|
62
|
+
for (const match of diff.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)) {
|
|
63
|
+
for (const file of [match[1], match[2]])
|
|
64
|
+
if (isControlFile(file))
|
|
65
|
+
files.add(file);
|
|
66
|
+
}
|
|
67
|
+
return [...files].sort();
|
|
68
|
+
}
|
|
57
69
|
function crashedGates(report) {
|
|
58
70
|
return Object.entries(report.summary).filter(([, status]) => status === 'ERROR').map(([id]) => id);
|
|
59
71
|
}
|
|
@@ -10,6 +10,7 @@ import { splitByChangedLines } from './changed-lines.js';
|
|
|
10
10
|
import { anchorInChangedFunction, changedFunctionSpans } from './changed-function-spans.js';
|
|
11
11
|
import { diffFromGit } from './git-diff.js';
|
|
12
12
|
import { reviewChange, toReviewFinding } from './review.js';
|
|
13
|
+
import { findingKey } from './quiet.js';
|
|
13
14
|
const LEAKY = [
|
|
14
15
|
'export async function notify(endpoint: string, signature: string) {',
|
|
15
16
|
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
@@ -114,6 +115,24 @@ describe('git-backed review', () => {
|
|
|
114
115
|
expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
|
|
115
116
|
expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
|
|
116
117
|
});
|
|
118
|
+
it('lets a change dismiss its own finding only when the review trusts the working tree', async () => {
|
|
119
|
+
write('src/old.ts', 'export const a = 1;\n');
|
|
120
|
+
write('.gitignore', '.rigour/*\n!.rigour/dismissed.json\n');
|
|
121
|
+
git('add', '-A');
|
|
122
|
+
git('commit', '-qm', 'init');
|
|
123
|
+
git('checkout', '-q', '-b', 'pr');
|
|
124
|
+
write('src/notify.ts', LEAKY);
|
|
125
|
+
const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true } } });
|
|
126
|
+
const key = findingKey((await reviewChange({ cwd: repo, config })).findings[0]);
|
|
127
|
+
write('.rigour/dismissed.json', JSON.stringify({ version: 1, entries: [{ key, reason: 'trust me', at: 'now' }] }));
|
|
128
|
+
git('add', '-A');
|
|
129
|
+
git('commit', '-qm', 'pr');
|
|
130
|
+
const trusting = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' } });
|
|
131
|
+
expect([trusting.status, trusting.dismissed]).toEqual(['PASS', 1]);
|
|
132
|
+
expect(trusting.controlFilesChanged).toEqual(['.rigour/dismissed.json']);
|
|
133
|
+
const independent = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' }, trustedRef: 'main' });
|
|
134
|
+
expect([independent.status, independent.dismissed]).toEqual(['FAIL', 0]);
|
|
135
|
+
});
|
|
117
136
|
it('runs every gate in a git worktree, where .git is a file', async () => {
|
|
118
137
|
write('src/a.ts', 'export const a = 1;\n');
|
|
119
138
|
git('add', '-A');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Where a problem was caught: while the agent wrote, when it asked to review, when it tried to finish, at the PR. */
|
|
2
|
+
export type CatchStage = 'edit' | 'review' | 'stop' | 'pr';
|
|
3
|
+
export interface Story {
|
|
4
|
+
id: string;
|
|
5
|
+
/** When the agent fixed it. */
|
|
6
|
+
at: string;
|
|
7
|
+
/** When Rigour first reported it; absent in stories kept before this was recorded. */
|
|
8
|
+
openedAt?: string;
|
|
9
|
+
stage: CatchStage;
|
|
10
|
+
file: string;
|
|
11
|
+
rule: string;
|
|
12
|
+
title: string;
|
|
13
|
+
details?: string;
|
|
14
|
+
/** Changed lines with context, each prefixed ' ', '-' or '+'. */
|
|
15
|
+
diff: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare const STORIES_FILE: string;
|
|
18
|
+
export declare function appendStory(cwd: string, story: Omit<Story, 'id'>): void;
|
|
19
|
+
/** Every story, oldest first; `since` keeps those at or after it. */
|
|
20
|
+
export declare function readStories(cwd: string, since?: Date): Story[];
|
|
21
|
+
/**
|
|
22
|
+
* The lines that changed between two versions of a file, with context: the common head and
|
|
23
|
+
* tail are trimmed and what is left between them is shown as removed then added.
|
|
24
|
+
*/
|
|
25
|
+
export declare function compactDiff(before: string, after: string): string[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Rigour stopped, as people read it: one entry per problem an agent fixed after Rigour
|
|
3
|
+
* reported it, with the stage that caught it and the code change that fixed it.
|
|
4
|
+
*
|
|
5
|
+
* agent-fixes.ts keeps the before and after only until `rigour learn` turns them into rules;
|
|
6
|
+
* this log keeps a small, permanent summary for Studio. It never holds whole files: only the
|
|
7
|
+
* changed lines with a little context, capped.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
export const STORIES_FILE = path.join('.rigour', 'stories.jsonl');
|
|
13
|
+
/** Kept on disk; older entries are dropped when the log is rewritten. */
|
|
14
|
+
const MAX_STORIES = 500;
|
|
15
|
+
const CONTEXT_LINES = 2;
|
|
16
|
+
const MAX_DIFF_LINES = 40;
|
|
17
|
+
const MAX_LINE_CHARS = 200;
|
|
18
|
+
export function appendStory(cwd, story) {
|
|
19
|
+
const entry = { id: crypto.createHash('sha256').update(`${story.at}\0${story.file}\0${story.rule}`).digest('hex').slice(0, 16), ...story };
|
|
20
|
+
const file = path.join(cwd, STORIES_FILE);
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
24
|
+
trim(file);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Stories are a view; failing to keep one must never break a review or a hook.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Every story, oldest first; `since` keeps those at or after it. */
|
|
31
|
+
export function readStories(cwd, since) {
|
|
32
|
+
let lines;
|
|
33
|
+
try {
|
|
34
|
+
lines = fs.readFileSync(path.join(cwd, STORIES_FILE), 'utf8').split('\n');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const stories = lines.flatMap(line => {
|
|
40
|
+
try {
|
|
41
|
+
return line.trim() ? [JSON.parse(line)] : [];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return since ? stories.filter(s => Date.parse(s.at) >= since.getTime()) : stories;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The lines that changed between two versions of a file, with context: the common head and
|
|
51
|
+
* tail are trimmed and what is left between them is shown as removed then added.
|
|
52
|
+
*/
|
|
53
|
+
export function compactDiff(before, after) {
|
|
54
|
+
const a = before.split('\n');
|
|
55
|
+
const b = after.split('\n');
|
|
56
|
+
let head = 0;
|
|
57
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
58
|
+
head++;
|
|
59
|
+
let tail = 0;
|
|
60
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
61
|
+
tail++;
|
|
62
|
+
const clip = (line) => (line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) + '…' : line);
|
|
63
|
+
const lines = [
|
|
64
|
+
...a.slice(Math.max(0, head - CONTEXT_LINES), head).map(l => ' ' + clip(l)),
|
|
65
|
+
...a.slice(head, a.length - tail).map(l => '-' + clip(l)),
|
|
66
|
+
...b.slice(head, b.length - tail).map(l => '+' + clip(l)),
|
|
67
|
+
...a.slice(a.length - tail, Math.min(a.length, a.length - tail + CONTEXT_LINES)).map(l => ' ' + clip(l)),
|
|
68
|
+
];
|
|
69
|
+
return lines.length > MAX_DIFF_LINES ? [...lines.slice(0, MAX_DIFF_LINES), ' …'] : lines;
|
|
70
|
+
}
|
|
71
|
+
function trim(file) {
|
|
72
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
73
|
+
if (lines.length > MAX_STORIES * 1.2)
|
|
74
|
+
fs.writeFileSync(file, lines.slice(-MAX_STORIES).join('\n') + '\n');
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { recordReviewOutcome } from './agent-fixes.js';
|
|
6
|
+
import { readAgentEvents } from './effectiveness.js';
|
|
7
|
+
import { recordLessonsServed, recordPrCatches } from './learning-events.js';
|
|
8
|
+
import { compactDiff, readStories } from './stories.js';
|
|
9
|
+
let cwd;
|
|
10
|
+
beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'stories-')); fs.mkdirSync(path.join(cwd, 'src')); });
|
|
11
|
+
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
12
|
+
const finding = (id, title) => ({ id, title, details: `${title}.`, severity: 'high', files: ['src/a.ts'], line: 2 });
|
|
13
|
+
describe('compactDiff', () => {
|
|
14
|
+
it('keeps only the changed lines and two lines of context', () => {
|
|
15
|
+
const before = ['a', 'b', 'c', 'old', 'd', 'e', 'f'].join('\n');
|
|
16
|
+
const after = ['a', 'b', 'c', 'new', 'd', 'e', 'f'].join('\n');
|
|
17
|
+
expect(compactDiff(before, after)).toEqual([' b', ' c', '-old', '+new', ' d', ' e']);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('stories from fixes', () => {
|
|
21
|
+
it('credits a fix to the stage that first reported it, with the code change', () => {
|
|
22
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers });\n");
|
|
23
|
+
recordReviewOutcome(cwd, [finding('semantic-bugs', 'Credential header follows redirects')], ['src/a.ts'], 'edit');
|
|
24
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers, redirect: 'error' });\n");
|
|
25
|
+
recordReviewOutcome(cwd, [], ['src/a.ts'], 'stop');
|
|
26
|
+
const [story] = readStories(cwd);
|
|
27
|
+
expect(story).toMatchObject({ stage: 'edit', file: 'src/a.ts', rule: 'semantic-bugs', title: 'Credential header follows redirects' });
|
|
28
|
+
expect(story.diff).toEqual(['-fetch(url, { headers });', "+fetch(url, { headers, redirect: 'error' });", ' ']);
|
|
29
|
+
});
|
|
30
|
+
it('writes no story for a finding someone dismissed, even after its file changed', async () => {
|
|
31
|
+
const { dismissFinding, findingKey } = await import('./quiet.js');
|
|
32
|
+
const f = finding('semantic-bugs', 'Looks risky');
|
|
33
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
|
|
34
|
+
recordReviewOutcome(cwd, [f], ['src/a.ts'], 'review');
|
|
35
|
+
dismissFinding(cwd, findingKey(f), 'intended');
|
|
36
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'y\n');
|
|
37
|
+
recordReviewOutcome(cwd, [], ['src/a.ts'], 'review');
|
|
38
|
+
expect(readStories(cwd)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
it('writes no story while the finding is still open', () => {
|
|
41
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
|
|
42
|
+
recordReviewOutcome(cwd, [finding('semantic-bugs', 'X')], ['src/a.ts'], 'review');
|
|
43
|
+
expect(readStories(cwd)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('learning events', () => {
|
|
47
|
+
it('records lessons served and PR catches, and nothing when there are none', () => {
|
|
48
|
+
recordLessonsServed(cwd, 'recall', []);
|
|
49
|
+
recordPrCatches(cwd, []);
|
|
50
|
+
expect(readAgentEvents(cwd)).toEqual([]);
|
|
51
|
+
recordLessonsServed(cwd, 'review', ['Never follow redirects with credentials', 'Never follow redirects with credentials']);
|
|
52
|
+
recordPrCatches(cwd, [finding('semantic-bugs', 'Unbounded query')]);
|
|
53
|
+
expect(readAgentEvents(cwd)).toEqual([
|
|
54
|
+
expect.objectContaining({ type: 'lessons_served', via: 'review', lessons: ['Never follow redirects with credentials'] }),
|
|
55
|
+
expect.objectContaining({ type: 'pr_catches', findings: [{ rule: 'semantic-bugs', title: 'Unbounded query', file: 'src/a.ts' }] }),
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Paths that steer Rigour itself; a change touching them is called out in the review. */
|
|
2
|
+
export declare function isControlFile(file: string): boolean;
|
|
3
|
+
/** A repository file's content at `ref`, or in the working tree when no ref is given; null when absent. */
|
|
4
|
+
export declare function readStateFile(cwd: string, rel: string, ref?: string): string | null;
|
|
5
|
+
/** The commit a branch left `base` at: the state a pull request is judged against. */
|
|
6
|
+
export declare function mergeBaseOf(cwd: string, base: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which copy of Rigour's own settings a review trusts.
|
|
3
|
+
*
|
|
4
|
+
* Dismissals, check outcomes and rigour.yml decide what a review reports. In the working tree they
|
|
5
|
+
* are whatever the change under review wrote, so a pull request could dismiss its own critical
|
|
6
|
+
* finding. An independent review reads them from the base instead (`ref`): a dismissal or setting
|
|
7
|
+
* added by the change takes effect once it is merged, the same rule CODEOWNERS follows.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
/** Paths that steer Rigour itself; a change touching them is called out in the review. */
|
|
13
|
+
export function isControlFile(file) {
|
|
14
|
+
return file === 'rigour.yml' || file.startsWith('.rigour/');
|
|
15
|
+
}
|
|
16
|
+
/** A repository file's content at `ref`, or in the working tree when no ref is given; null when absent. */
|
|
17
|
+
export function readStateFile(cwd, rel, ref) {
|
|
18
|
+
if (!ref) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readFileSync(path.join(cwd, rel), 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = spawnSync('git', ['show', `${ref}:${rel.split(path.sep).join('/')}`], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
27
|
+
return result.status === 0 ? result.stdout : null;
|
|
28
|
+
}
|
|
29
|
+
/** The commit a branch left `base` at: the state a pull request is judged against. */
|
|
30
|
+
export function mergeBaseOf(cwd, base) {
|
|
31
|
+
const result = spawnSync('git', ['merge-base', base, 'HEAD'], { cwd, encoding: 'utf8' });
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
throw new Error(`git merge-base ${base} HEAD failed: ${result.stderr.trim()}`);
|
|
34
|
+
return result.stdout.trim();
|
|
35
|
+
}
|