@rigour-labs/core 6.5.4 → 6.6.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/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 -3
- package/dist/deep/risk.js +19 -3
- package/dist/deep/risk.test.js +28 -1
- package/dist/deep/router.d.ts +2 -1
- package/dist/deep/router.js +2 -2
- package/dist/firewall/execution-receipt.d.ts +5 -0
- package/dist/firewall/execution-receipt.js +10 -1
- package/dist/firewall/trusted-control.test.js +10 -0
- package/dist/gates/deep-analysis.js +3 -3
- package/dist/hooks/checker.js +18 -0
- package/dist/hooks/checker.test.js +10 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/review/agent-fixes.d.ts +25 -2
- package/dist/review/agent-fixes.js +27 -6
- package/dist/review/effectiveness.d.ts +13 -0
- package/dist/review/learning-events.d.ts +14 -0
- package/dist/review/learning-events.js +21 -0
- package/dist/review/receipt.d.ts +63 -0
- package/dist/review/receipt.js +73 -0
- package/dist/review/receipt.test.d.ts +1 -0
- package/dist/review/receipt.test.js +60 -0
- package/dist/review/review-task.d.ts +3 -1
- package/dist/review/review-task.js +5 -4
- package/dist/review/review-task.test.js +9 -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-learning/team-lessons.d.ts +4 -0
- package/dist/review-learning/team-lessons.js +9 -1
- 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.js +1 -1
- package/dist/utils/user-state.js +1 -1
- package/package.json +6 -6
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { type ReviewLesson } from './lessons.js';
|
|
2
2
|
export type LessonMode = 'verified' | 'all' | 'off';
|
|
3
|
+
/** Lessons a person or repeated evidence verified reach the reviewer unless a team turns them off. */
|
|
4
|
+
export declare const DEFAULT_LESSON_MODE: LessonMode;
|
|
5
|
+
/** The team's review lessons in play for this mode: none when off, verified ones by default. */
|
|
6
|
+
export declare function activeLessons(cwd: string, mode?: LessonMode): ReviewLesson[];
|
|
3
7
|
export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode): ReviewLesson[];
|
|
4
8
|
export declare function lessonsSection(lessons: ReviewLesson[]): string;
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parseDiff } from '../utils/diff.js';
|
|
7
7
|
import { matchLessons, readLessons } from './lessons.js';
|
|
8
|
-
|
|
8
|
+
/** Lessons a person or repeated evidence verified reach the reviewer unless a team turns them off. */
|
|
9
|
+
export const DEFAULT_LESSON_MODE = 'verified';
|
|
10
|
+
/** The team's review lessons in play for this mode: none when off, verified ones by default. */
|
|
11
|
+
export function activeLessons(cwd, mode = DEFAULT_LESSON_MODE) {
|
|
12
|
+
if (mode === 'off')
|
|
13
|
+
return [];
|
|
14
|
+
return readLessons(cwd).filter(l => mode === 'all' || l.state === 'verified');
|
|
15
|
+
}
|
|
16
|
+
export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE) {
|
|
9
17
|
if (mode === 'off')
|
|
10
18
|
return [];
|
|
11
19
|
const lessons = readLessons(cwd);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
|
|
2
|
+
export declare const DEAD_CACHE_TYPES: readonly ["static", "component"];
|
|
3
|
+
export interface CacheCleanupReport {
|
|
4
|
+
removed: number;
|
|
5
|
+
kept: number;
|
|
6
|
+
sizeBefore: number;
|
|
7
|
+
sizeAfter: number;
|
|
8
|
+
vacuumed: boolean;
|
|
9
|
+
/** Why the file was not shrunk, when it was not. */
|
|
10
|
+
vacuumSkipped?: string;
|
|
11
|
+
}
|
|
12
|
+
/** How many dead rows the database holds, and its size on disk; null when there is no database. */
|
|
13
|
+
export declare function deadCacheRows(dbPath?: string): Promise<{
|
|
14
|
+
rows: number;
|
|
15
|
+
bytes: number;
|
|
16
|
+
} | null>;
|
|
17
|
+
export declare function cleanContextCache(dbPath?: string, freeBytes?: typeof freeSpace): Promise<CacheCleanupReport>;
|
|
18
|
+
declare function freeSpace(dir: string): number;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing context-cache rows nothing reads: the static and component layers that index sync wrote
|
|
3
|
+
* on every edit until it was removed (millions of rows, gigabytes, in real use).
|
|
4
|
+
*
|
|
5
|
+
* Built for a database that is large and a disk that is nearly full. Deleting the rows one by one
|
|
6
|
+
* would copy every changed page into the write-ahead log (as much again on disk); instead the rows
|
|
7
|
+
* worth keeping are set aside, the table is dropped (its pages are freed, not rewritten) and
|
|
8
|
+
* recreated. VACUUM, which hands the freed space back to the disk, needs room for a copy of what
|
|
9
|
+
* remains, so it runs only when that room exists.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { DB_PATH, openDatabase } from './db.js';
|
|
14
|
+
/** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
|
|
15
|
+
export const DEAD_CACHE_TYPES = ['static', 'component'];
|
|
16
|
+
const DEAD_LIST = DEAD_CACHE_TYPES.map(t => `'${t}'`).join(', ');
|
|
17
|
+
/** VACUUM writes a copy of the live data; ask for that much free space, with headroom. */
|
|
18
|
+
const VACUUM_HEADROOM = 1.2;
|
|
19
|
+
/** How many dead rows the database holds, and its size on disk; null when there is no database. */
|
|
20
|
+
export async function deadCacheRows(dbPath = DB_PATH) {
|
|
21
|
+
if (!fs.existsSync(dbPath))
|
|
22
|
+
return null;
|
|
23
|
+
const db = await openDatabase(dbPath);
|
|
24
|
+
if (!db)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const row = await db.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`);
|
|
28
|
+
return { rows: Number(row?.n ?? 0), bytes: fileBytes(dbPath) };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await db.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function cleanContextCache(dbPath = DB_PATH, freeBytes = freeSpace) {
|
|
35
|
+
const sizeBefore = fileBytes(dbPath);
|
|
36
|
+
const db = await openDatabase(dbPath);
|
|
37
|
+
if (!db)
|
|
38
|
+
return { removed: 0, kept: 0, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'SQLite is not available' };
|
|
39
|
+
try {
|
|
40
|
+
const { removed, kept } = await dropDeadRows(db);
|
|
41
|
+
if (removed === 0)
|
|
42
|
+
return { removed, kept, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'nothing to remove' };
|
|
43
|
+
const live = await liveBytes(db);
|
|
44
|
+
const available = freeBytes(path.dirname(dbPath));
|
|
45
|
+
if (available < live * VACUUM_HEADROOM) {
|
|
46
|
+
return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: false,
|
|
47
|
+
vacuumSkipped: `needs about ${mb(live * VACUUM_HEADROOM)} free to shrink the file, ${mb(available)} available; the space is reused by Rigour either way` };
|
|
48
|
+
}
|
|
49
|
+
await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
50
|
+
await db.exec('VACUUM');
|
|
51
|
+
await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
52
|
+
return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: true };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Set the readable rows aside, drop the table, recreate it with its indexes, put them back. */
|
|
59
|
+
async function dropDeadRows(db) {
|
|
60
|
+
return db.transaction(async (tx) => {
|
|
61
|
+
const removed = Number((await tx.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`))?.n ?? 0);
|
|
62
|
+
if (removed === 0)
|
|
63
|
+
return { removed, kept: 0 };
|
|
64
|
+
const schema = await tx.all(`SELECT sql FROM sqlite_master WHERE tbl_name = 'context_cache' AND sql IS NOT NULL ORDER BY type DESC`);
|
|
65
|
+
await tx.exec(`CREATE TEMP TABLE context_cache_keep AS SELECT * FROM context_cache WHERE cache_type NOT IN (${DEAD_LIST})`);
|
|
66
|
+
await tx.exec('DROP TABLE context_cache');
|
|
67
|
+
for (const { sql } of schema)
|
|
68
|
+
await tx.exec(sql); // the table first (type 'table' sorts after 'index' descending), then its indexes
|
|
69
|
+
await tx.exec('INSERT INTO context_cache SELECT * FROM context_cache_keep');
|
|
70
|
+
const kept = Number((await tx.get('SELECT COUNT(*) AS n FROM context_cache'))?.n ?? 0);
|
|
71
|
+
await tx.exec('DROP TABLE context_cache_keep');
|
|
72
|
+
return { removed, kept };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function liveBytes(db) {
|
|
76
|
+
const pages = Number((await db.get('PRAGMA page_count'))?.page_count ?? 0);
|
|
77
|
+
const free = Number((await db.get('PRAGMA freelist_count'))?.freelist_count ?? 0);
|
|
78
|
+
const size = Number((await db.get('PRAGMA page_size'))?.page_size ?? 4096);
|
|
79
|
+
return (pages - free) * size;
|
|
80
|
+
}
|
|
81
|
+
function fileBytes(dbPath) {
|
|
82
|
+
return [dbPath, `${dbPath}-wal`].reduce((n, f) => n + (fs.existsSync(f) ? fs.statSync(f).size : 0), 0);
|
|
83
|
+
}
|
|
84
|
+
function freeSpace(dir) {
|
|
85
|
+
const stats = fs.statfsSync(dir);
|
|
86
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
87
|
+
}
|
|
88
|
+
function mb(bytes) {
|
|
89
|
+
return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { cleanContextCache, deadCacheRows } from './cache-cleanup.js';
|
|
6
|
+
import { openDatabase } from './db.js';
|
|
7
|
+
let dir;
|
|
8
|
+
let dbPath;
|
|
9
|
+
async function seed(rows) {
|
|
10
|
+
const db = (await openDatabase(dbPath));
|
|
11
|
+
// One transaction: row-at-a-time commits are slow enough on Windows to time the test out.
|
|
12
|
+
await db.exec('BEGIN');
|
|
13
|
+
for (const [key, type] of rows) {
|
|
14
|
+
await db.run(`INSERT INTO context_cache (cache_key, cache_type, repo, branch, dependency_fingerprint, payload_json, payload_tokens, created_at)
|
|
15
|
+
VALUES (?, ?, 'r', 'main', 'f', ?, 1, 0)`, key, type, JSON.stringify({ padding: 'x'.repeat(2000) }));
|
|
16
|
+
}
|
|
17
|
+
await db.exec('COMMIT');
|
|
18
|
+
await db.close();
|
|
19
|
+
}
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-cleanup-')); // a temporary database: never ~/.rigour
|
|
22
|
+
dbPath = path.join(dir, 'rigour.db');
|
|
23
|
+
});
|
|
24
|
+
// Windows keeps a just-closed SQLite file locked for a moment: retry the removal instead of failing.
|
|
25
|
+
afterEach(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
|
|
26
|
+
// Real SQLite files on disk: slower on Windows runners than the 5 s default allows.
|
|
27
|
+
describe('cleanContextCache', { timeout: 30_000 }, () => {
|
|
28
|
+
it('removes the unread layers, keeps the readable rows and the indexes, and shrinks the file', async () => {
|
|
29
|
+
await seed([
|
|
30
|
+
...Array.from({ length: 200 }, (_, i) => [`static:${i}`, 'static']),
|
|
31
|
+
...Array.from({ length: 200 }, (_, i) => [`component:${i}`, 'component']),
|
|
32
|
+
['semantic:a', 'semantic'], ['checkpoint:b', 'checkpoint'],
|
|
33
|
+
]);
|
|
34
|
+
expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 400 });
|
|
35
|
+
const report = await cleanContextCache(dbPath, () => Number.MAX_SAFE_INTEGER);
|
|
36
|
+
expect(report).toMatchObject({ removed: 400, kept: 2, vacuumed: true });
|
|
37
|
+
expect(report.sizeAfter).toBeLessThan(report.sizeBefore);
|
|
38
|
+
const db = (await openDatabase(dbPath));
|
|
39
|
+
expect((await db.all('SELECT cache_key FROM context_cache ORDER BY cache_key')).map(r => r.cache_key)).toEqual(['checkpoint:b', 'semantic:a']);
|
|
40
|
+
expect((await db.all("SELECT name FROM sqlite_master WHERE tbl_name = 'context_cache' AND type = 'index'")).map(r => r.name)).toContain('idx_context_cache_type');
|
|
41
|
+
await db.close();
|
|
42
|
+
expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 0 });
|
|
43
|
+
});
|
|
44
|
+
it('still removes the rows but leaves the file size alone when the disk lacks room to shrink it', async () => {
|
|
45
|
+
await seed([['static:1', 'static'], ['semantic:a', 'semantic']]);
|
|
46
|
+
const report = await cleanContextCache(dbPath, () => 0);
|
|
47
|
+
expect(report).toMatchObject({ removed: 1, kept: 1, vacuumed: false });
|
|
48
|
+
expect(report.vacuumSkipped).toContain('free to shrink the file');
|
|
49
|
+
});
|
|
50
|
+
it('does nothing when there is nothing to remove', async () => {
|
|
51
|
+
await seed([['semantic:a', 'semantic']]);
|
|
52
|
+
expect(await cleanContextCache(dbPath, () => 0)).toMatchObject({ removed: 0, vacuumed: false, vacuumSkipped: 'nothing to remove' });
|
|
53
|
+
});
|
|
54
|
+
});
|
package/dist/storage/db.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import fs from 'fs-extra';
|
|
12
12
|
import { createRequire } from 'module';
|
|
13
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Optional dynamic import of sqlite3
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
@@ -42,7 +43,8 @@ function loadSqlite3() {
|
|
|
42
43
|
sqlite3Module = null;
|
|
43
44
|
return sqlite3Module;
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
// RIGOUR_HOME when set (tests, sandboxes) so nothing but a real run touches ~/.rigour.
|
|
47
|
+
const RIGOUR_DIR = rigourUserDir();
|
|
46
48
|
const DB_PATH = path.join(RIGOUR_DIR, 'rigour.db');
|
|
47
49
|
/** Current schema version — bump when adding migrations. */
|
|
48
50
|
const SCHEMA_VERSION = 7;
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Everything in one file: ~/.rigour/rigour.db
|
|
4
4
|
*/
|
|
5
5
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
|
|
6
|
+
export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES, type CacheCleanupReport } from './cache-cleanup.js';
|
|
6
7
|
export type { RigourDB, CompactResult } from './db.js';
|
|
7
8
|
export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
|
|
8
9
|
export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
|
package/dist/storage/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Everything in one file: ~/.rigour/rigour.db
|
|
4
4
|
*/
|
|
5
5
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
|
|
6
|
+
export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES } from './cache-cleanup.js';
|
|
6
7
|
export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
|
|
7
8
|
export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
|
|
8
9
|
export { reinforcePattern, decayPatterns, getStrongPatterns, getPatterns, getHardRules } from './patterns.js';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
2
2
|
import { chmod, link, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
import os from 'os';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
|
|
5
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
6
|
+
const KEY_PATH = path.join(rigourUserDir(), 'team-cache.key');
|
|
7
7
|
let fileKeyPromise;
|
|
8
8
|
function decodeKey(value, source) {
|
|
9
9
|
const normalized = value.trim();
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
-
import os from 'os';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import { openDatabase } from './db.js';
|
|
5
4
|
import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js';
|
|
6
5
|
import { TEAM_SCHEMA } from './team-schema.js';
|
|
7
6
|
import { diagnoseMissingMembership, explainTeamConnectionError } from './team-diagnostics.js';
|
|
7
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
8
8
|
import { TEAM_VECTOR_SCHEMA, backfillConfiguredTeamEmbeddings, getTeamSemanticHealth, searchConfiguredTeamKnowledge, upsertTeamLessonEmbedding, } from './team-vector-store.js';
|
|
9
9
|
const DEFAULT_SEMANTIC = {
|
|
10
10
|
provider: 'pgvector',
|
|
11
11
|
model: 'Xenova/all-MiniLM-L6-v2',
|
|
12
12
|
dimensions: 384,
|
|
13
13
|
};
|
|
14
|
-
export const TEAM_CONFIG_PATH = path.join(
|
|
14
|
+
export const TEAM_CONFIG_PATH = path.join(rigourUserDir(), 'team.json');
|
|
15
15
|
function nonEmpty(value) {
|
|
16
16
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
17
17
|
}
|
package/dist/types/index.js
CHANGED
|
@@ -333,7 +333,7 @@ export const GatesSchema = z.object({
|
|
|
333
333
|
budget_ms: z.number().optional(), // whole deep run; files not started in time are reported as skipped
|
|
334
334
|
agentic: z.boolean().optional(), // cloud tier: the model may read the repository while it reviews (default true)
|
|
335
335
|
repo_rules: z.boolean().optional(), // show the reviewer the rules in AGENTS.md / CLAUDE.md / Cursor rules that name what the change touches (default false)
|
|
336
|
-
review_lessons: z.enum(['verified', 'all', 'off']).optional(), // the team's past review lessons shown to the reviewer
|
|
336
|
+
review_lessons: z.enum(['verified', 'all', 'off']).optional(), // the team's past review lessons: they raise a function's risk and are shown to the reviewer. verified (default), all, or off
|
|
337
337
|
router: z.object({
|
|
338
338
|
enabled: z.boolean().optional(), // default true
|
|
339
339
|
min_score: z.number().optional(), // functions below this get the deterministic gates only
|
package/dist/utils/user-state.js
CHANGED
|
@@ -13,7 +13,7 @@ export function rigourUserDir() {
|
|
|
13
13
|
export function repoStateDir(cwd) {
|
|
14
14
|
let root = path.resolve(cwd);
|
|
15
15
|
try {
|
|
16
|
-
root = fs.realpathSync(root);
|
|
16
|
+
root = fs.realpathSync.native(root);
|
|
17
17
|
}
|
|
18
18
|
catch { /* not created yet: the resolved path is the key */ }
|
|
19
19
|
const key = crypto.createHash('sha256').update(root).digest('hex').slice(0, 16);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.1",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -70,11 +70,11 @@
|
|
|
70
70
|
"pg": "^8.16.3",
|
|
71
71
|
"sqlite3": "^5.1.7",
|
|
72
72
|
"openai": "^4.104.0",
|
|
73
|
-
"@rigour-labs/brain-darwin-
|
|
74
|
-
"@rigour-labs/brain-
|
|
75
|
-
"@rigour-labs/brain-
|
|
76
|
-
"@rigour-labs/brain-linux-x64": "6.
|
|
77
|
-
"@rigour-labs/brain-win-x64": "6.
|
|
73
|
+
"@rigour-labs/brain-darwin-x64": "6.6.1",
|
|
74
|
+
"@rigour-labs/brain-linux-arm64": "6.6.1",
|
|
75
|
+
"@rigour-labs/brain-darwin-arm64": "6.6.1",
|
|
76
|
+
"@rigour-labs/brain-linux-x64": "6.6.1",
|
|
77
|
+
"@rigour-labs/brain-win-x64": "6.6.1"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/fs-extra": "^11.0.4",
|