@rigour-labs/core 6.7.1 → 6.7.4
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/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/review.js +19 -4
- package/dist/review/review.test.js +11 -0
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +122 -20
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +149 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/services/temporal-drift.js +1 -1
- package/dist/storage/db.d.ts +4 -6
- package/dist/storage/db.js +63 -90
- package/dist/storage/db.test.d.ts +1 -0
- package/dist/storage/db.test.js +46 -0
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- package/package.json +8 -9
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning reviews into rules: a frontier model rewrites each point a person made, which the pull
|
|
3
|
+
* request then acted on, as the plain-language rule behind it, or says it is not a rule (a test
|
|
4
|
+
* report, a status note, a one-off fix). Memory, never training: the rule is text the next judge and
|
|
5
|
+
* agent read, it keeps the person's own words as evidence, and it stays a candidate until the same
|
|
6
|
+
* rule recurs in two or more pull requests or a person promotes it.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
/** Points per call: enough to share the context, few enough that every one gets an answer. */
|
|
10
|
+
const BATCH = 20;
|
|
11
|
+
const MAX_RULE = 200;
|
|
12
|
+
function rulesPrompt(lessons) {
|
|
13
|
+
const points = lessons.map(l => ({ id: l.id, pr: l.evidence[0]?.pr, file: l.file || null, said: l.text }));
|
|
14
|
+
return `A team's senior reviewers teach through code review. Below are points people made in reviews of
|
|
15
|
+
this repository, each of which the pull request then acted on. For each point, decide whether it
|
|
16
|
+
teaches a rule the team would want applied to future changes, beyond this one pull request.
|
|
17
|
+
|
|
18
|
+
- If it does, write that rule as one plain imperative sentence of at most ${MAX_RULE} characters
|
|
19
|
+
("Apply every filter known before a read in the query itself."). Keep a name only when the rule is
|
|
20
|
+
about that code. Give "file" when the rule is about one file, else null.
|
|
21
|
+
- If it is a test or check report, a status note, praise, a question, or a fix of one specific bug that
|
|
22
|
+
teaches nothing general, give "rule": null.
|
|
23
|
+
|
|
24
|
+
Do not read or change any file; answer from the points alone. Your final message must be ONLY this
|
|
25
|
+
JSON: {"rules":[{"id":"<the point's id>","rule":"..." | null,"file":"<path>" | null}]}
|
|
26
|
+
|
|
27
|
+
Points:
|
|
28
|
+
${JSON.stringify(points, null, 1)}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The points as the rules the model wrote: a point it called no rule is kept and marked so (never
|
|
32
|
+
* promoted again), a point it did not answer is kept as it was (a failed call never loses evidence), and
|
|
33
|
+
* the person's words stay in the evidence. A rule about a file keeps that file only when the point was already about it.
|
|
34
|
+
*/
|
|
35
|
+
export async function rulesFromReviews(lessons, write) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let rules = 0, dropped = 0, unanswered = 0;
|
|
38
|
+
for (let i = 0; i < lessons.length; i += BATCH) {
|
|
39
|
+
const batch = lessons.slice(i, i + BATCH);
|
|
40
|
+
const answers = parseRules(await write(rulesPrompt(batch)));
|
|
41
|
+
for (const lesson of batch) {
|
|
42
|
+
const answer = answers.get(lesson.id);
|
|
43
|
+
if (!answer) {
|
|
44
|
+
unanswered++;
|
|
45
|
+
out.push(lesson);
|
|
46
|
+
}
|
|
47
|
+
else if (!answer.rule) {
|
|
48
|
+
// Kept, marked: a point the writer found no rule in is never promoted, or paid for, again.
|
|
49
|
+
dropped++;
|
|
50
|
+
out.push({ ...lesson, evidence: [...lesson.evidence, { kind: 'norule', pr: lesson.evidence[0]?.pr ?? 0, comment: `norule-${lesson.id}`, author: '', detail: 'the rule writer found no rule in it' }] });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
rules++;
|
|
54
|
+
const file = answer.file && answer.file === lesson.file ? lesson.file : '';
|
|
55
|
+
out.push({
|
|
56
|
+
...lesson, text: answer.rule, file,
|
|
57
|
+
id: crypto.createHash('sha256').update(`${file}\u0000${answer.rule.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}`).digest('hex').slice(0, 12),
|
|
58
|
+
evidence: lesson.evidence.map(e => ({ ...e, said: e.said ?? lesson.text })),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { lessons: out, rules, dropped, unanswered };
|
|
64
|
+
}
|
|
65
|
+
function parseRules(text) {
|
|
66
|
+
const found = new Map();
|
|
67
|
+
if (!text)
|
|
68
|
+
return found;
|
|
69
|
+
const start = text.indexOf('{"rules"') >= 0 ? text.indexOf('{"rules"') : text.indexOf('{');
|
|
70
|
+
for (let end = text.lastIndexOf('}'); start >= 0 && end > start; end = text.lastIndexOf('}', end - 1)) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
73
|
+
for (const r of Array.isArray(parsed?.rules) ? parsed.rules : []) {
|
|
74
|
+
if (typeof r?.id !== 'string')
|
|
75
|
+
continue;
|
|
76
|
+
const rule = typeof r.rule === 'string' && r.rule.trim().length >= 12 ? r.rule.trim().slice(0, MAX_RULE) : null;
|
|
77
|
+
found.set(r.id, { rule, file: typeof r.file === 'string' ? r.file : null });
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// not a whole object yet: try a shorter one
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
@@ -4,5 +4,21 @@ export type LessonMode = 'verified' | 'all' | 'off';
|
|
|
4
4
|
export declare const DEFAULT_LESSON_MODE: LessonMode;
|
|
5
5
|
/** The team's review lessons in play for this mode: none when off, verified ones by default. */
|
|
6
6
|
export declare function activeLessons(cwd: string, mode?: LessonMode): ReviewLesson[];
|
|
7
|
-
|
|
7
|
+
/** `standards`: how many team standards may come with the file lessons (a judge reading a whole pull request takes more than an agent's one question). */
|
|
8
|
+
export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode, standards?: number): ReviewLesson[];
|
|
9
|
+
/** The points this team rejected that a change touches: what the judges are told is settled. */
|
|
10
|
+
export declare function rejectedForDiff(cwd: string, diff: string): ReviewLesson[];
|
|
11
|
+
/** A lesson as a judge or agent sees it, in one place. */
|
|
12
|
+
export interface LessonView {
|
|
13
|
+
file: string;
|
|
14
|
+
text: string;
|
|
15
|
+
prs: number[];
|
|
16
|
+
said?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function lessonView(l: ReviewLesson): LessonView;
|
|
19
|
+
/**
|
|
20
|
+
* One line per lesson: where it applies, the rule, and, when a model wrote the rule from a person's
|
|
21
|
+
* point, their own words, so anyone can see when the rule went beyond what was said.
|
|
22
|
+
*/
|
|
23
|
+
export declare function describeLesson(l: LessonView): string;
|
|
8
24
|
export declare function lessonsSection(lessons: ReviewLesson[]): string;
|
|
@@ -13,20 +13,44 @@ export function activeLessons(cwd, mode = DEFAULT_LESSON_MODE) {
|
|
|
13
13
|
return [];
|
|
14
14
|
return readLessons(cwd).filter(l => mode === 'all' || l.state === 'verified');
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/** `standards`: how many team standards may come with the file lessons (a judge reading a whole pull request takes more than an agent's one question). */
|
|
17
|
+
export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE, standards) {
|
|
17
18
|
if (mode === 'off')
|
|
18
19
|
return [];
|
|
19
20
|
const lessons = readLessons(cwd);
|
|
20
21
|
if (lessons.length === 0)
|
|
21
22
|
return [];
|
|
22
|
-
|
|
23
|
+
return matchLessons(lessons, changeShape(diff), { includeCandidates: mode === 'all', ...(standards !== undefined ? { standards } : {}) });
|
|
24
|
+
}
|
|
25
|
+
/** The points this team rejected that a change touches: what the judges are told is settled. */
|
|
26
|
+
export function rejectedForDiff(cwd, diff) {
|
|
27
|
+
const rejected = readLessons(cwd).filter(l => l.state === 'rejected');
|
|
28
|
+
if (rejected.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const matched = new Set(matchLessons(rejected.map(l => ({ ...l, state: 'verified' })), changeShape(diff), { standards: 5 }).map(l => l.id));
|
|
31
|
+
return rejected.filter(l => matched.has(l.id));
|
|
32
|
+
}
|
|
33
|
+
function changeShape(diff) {
|
|
23
34
|
const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
return { files: Object.keys(parseDiff(diff)), symbols: new Set(added.match(/[A-Za-z_$][\w$]{3,}/g) ?? []) };
|
|
36
|
+
}
|
|
37
|
+
export function lessonView(l) {
|
|
38
|
+
const said = l.evidence.find(e => e.said && e.said !== l.text)?.said;
|
|
39
|
+
return { file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))], ...(said ? { said } : {}) };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One line per lesson: where it applies, the rule, and, when a model wrote the rule from a person's
|
|
43
|
+
* point, their own words, so anyone can see when the rule went beyond what was said.
|
|
44
|
+
*/
|
|
45
|
+
export function describeLesson(l) {
|
|
46
|
+
const where = l.file ? `${l.file}: ` : 'team standard: ';
|
|
47
|
+
const said = l.said ? ` (in their words: "${l.said}")` : '';
|
|
48
|
+
const prs = l.prs.length ? ` (acted on in PR ${l.prs.map(p => `#${p}`).join(', ')})` : '';
|
|
49
|
+
return `${where}${l.text}${said}${prs}`;
|
|
26
50
|
}
|
|
27
51
|
export function lessonsSection(lessons) {
|
|
28
52
|
if (lessons.length === 0)
|
|
29
53
|
return '';
|
|
30
|
-
const lines = lessons.map(l => `- ${
|
|
54
|
+
const lines = lessons.map(l => `- ${describeLesson(lessonView(l))}`);
|
|
31
55
|
return `TEAM LESSONS (this team asked for these in past reviews; check whether the change repeats any):\n${lines.join('\n')}`;
|
|
32
56
|
}
|
|
@@ -94,7 +94,7 @@ export async function generateTemporalDriftReport(cwd, maxScans = 200) {
|
|
|
94
94
|
if (!db) {
|
|
95
95
|
if (!_sqliteWarningShown) {
|
|
96
96
|
_sqliteWarningShown = true;
|
|
97
|
-
Logger.warn('Temporal drift: SQLite not available
|
|
97
|
+
Logger.warn('Temporal drift: SQLite not available (it is built into Node 22.13 and later), so there is no scan history');
|
|
98
98
|
}
|
|
99
99
|
return null;
|
|
100
100
|
}
|
package/dist/storage/db.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
declare const RIGOUR_DIR: string;
|
|
2
2
|
declare const DB_PATH: string;
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The database behind a promise API: run/get/all/exec return Promises, so callers do not change
|
|
5
|
+
* with the driver, and a later move to a worker or an async driver stays inside this file.
|
|
6
6
|
*/
|
|
7
7
|
export interface RigourDB {
|
|
8
8
|
/** Execute a write statement (INSERT/UPDATE/DELETE). Returns { changes }. */
|
|
@@ -23,7 +23,7 @@ export interface RigourDB {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Open (or create) the Rigour SQLite database.
|
|
26
|
-
* Returns null
|
|
26
|
+
* Returns null where this Node has no node:sqlite.
|
|
27
27
|
*/
|
|
28
28
|
export declare function openDatabase(dbPath?: string): Promise<RigourDB | null>;
|
|
29
29
|
/**
|
|
@@ -44,8 +44,6 @@ export declare function getDatabaseSize(): number;
|
|
|
44
44
|
* Reset the database — delete and recreate from scratch.
|
|
45
45
|
*/
|
|
46
46
|
export declare function resetDatabase(): void;
|
|
47
|
-
/**
|
|
48
|
-
* Check if SQLite is available (sqlite3 installed)
|
|
49
|
-
*/
|
|
47
|
+
/** Whether this Node has SQLite (node:sqlite, Node 22.13 or later). */
|
|
50
48
|
export declare function isSQLiteAvailable(): boolean;
|
|
51
49
|
export { RIGOUR_DIR, DB_PATH };
|
package/dist/storage/db.js
CHANGED
|
@@ -3,44 +3,38 @@
|
|
|
3
3
|
* Single file at ~/.rigour/rigour.db stores all scan history, findings,
|
|
4
4
|
* learned patterns, and feedback. ACID-safe, portable, queryable.
|
|
5
5
|
*
|
|
6
|
-
* Uses
|
|
7
|
-
* All public APIs are async.
|
|
6
|
+
* Uses Node's built-in SQLite (node:sqlite): nothing to install or compile, and no native addon to
|
|
7
|
+
* crash a worker thread. All public APIs are async. Where node:sqlite is missing (Node before
|
|
8
|
+
* 22.13), storage is off and every caller degrades as before.
|
|
8
9
|
*/
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import fs from 'fs-extra';
|
|
11
12
|
import { createRequire } from 'module';
|
|
12
13
|
import { rigourUserDir } from '../utils/user-state.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
function
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (sqlite3Module)
|
|
36
|
-
return sqlite3Module;
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// Try next path
|
|
40
|
-
}
|
|
14
|
+
let sqliteModule;
|
|
15
|
+
/**
|
|
16
|
+
* node:sqlite, or null where this Node has none. Node 22 prints an "experimental feature" warning on
|
|
17
|
+
* the first load; it says nothing a Rigour user can act on, so that one warning is not shown.
|
|
18
|
+
*/
|
|
19
|
+
function loadSqlite() {
|
|
20
|
+
if (sqliteModule !== undefined)
|
|
21
|
+
return sqliteModule;
|
|
22
|
+
const emitWarning = process.emitWarning;
|
|
23
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
24
|
+
if (String(warning?.message ?? warning).startsWith('SQLite is an experimental feature'))
|
|
25
|
+
return;
|
|
26
|
+
return emitWarning.call(process, warning, ...rest);
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
sqliteModule = createRequire(import.meta.url)('node:sqlite');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
sqliteModule = null;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
process.emitWarning = emitWarning;
|
|
41
36
|
}
|
|
42
|
-
|
|
43
|
-
return sqlite3Module;
|
|
37
|
+
return sqliteModule;
|
|
44
38
|
}
|
|
45
39
|
// RIGOUR_HOME when set (tests, sandboxes) so nothing but a real run touches ~/.rigour.
|
|
46
40
|
const RIGOUR_DIR = rigourUserDir();
|
|
@@ -254,52 +248,41 @@ CREATE INDEX IF NOT EXISTS idx_repositories_last_seen ON repositories(last_seen)
|
|
|
254
248
|
CREATE INDEX IF NOT EXISTS idx_interaction_events_repo_created ON interaction_events(repository_id, created_at);
|
|
255
249
|
CREATE INDEX IF NOT EXISTS idx_interaction_events_request ON interaction_events(request_id);
|
|
256
250
|
`;
|
|
251
|
+
/**
|
|
252
|
+
* A value as SQLite stores it. node:sqlite refuses what sqlite3 converted silently, so the same
|
|
253
|
+
* conversions happen here: undefined is NULL, a boolean is 1 or 0, a Date is its epoch milliseconds.
|
|
254
|
+
*/
|
|
255
|
+
function bindable(value) {
|
|
256
|
+
if (value === undefined)
|
|
257
|
+
return null;
|
|
258
|
+
if (typeof value === 'boolean')
|
|
259
|
+
return value ? 1 : 0;
|
|
260
|
+
if (value instanceof Date)
|
|
261
|
+
return value.getTime();
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
/** Rows as plain objects (node:sqlite returns them without a prototype). */
|
|
265
|
+
function plain(row) {
|
|
266
|
+
return row && typeof row === 'object' ? { ...row } : row;
|
|
267
|
+
}
|
|
257
268
|
function wrapDatabase(raw) {
|
|
269
|
+
const prepare = (sql) => raw.prepare(sql);
|
|
258
270
|
const db = {
|
|
259
|
-
run(sql, ...params) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (err)
|
|
263
|
-
return reject(err);
|
|
264
|
-
resolve({ changes: this.changes, lastID: this.lastID });
|
|
265
|
-
});
|
|
266
|
-
});
|
|
271
|
+
async run(sql, ...params) {
|
|
272
|
+
const result = prepare(sql).run(...params.map(bindable));
|
|
273
|
+
return { changes: Number(result.changes), lastID: Number(result.lastInsertRowid) };
|
|
267
274
|
},
|
|
268
|
-
get(sql, ...params) {
|
|
269
|
-
return
|
|
270
|
-
raw.get(sql, ...params, (err, row) => {
|
|
271
|
-
if (err)
|
|
272
|
-
return reject(err);
|
|
273
|
-
resolve(row);
|
|
274
|
-
});
|
|
275
|
-
});
|
|
275
|
+
async get(sql, ...params) {
|
|
276
|
+
return plain(prepare(sql).get(...params.map(bindable)));
|
|
276
277
|
},
|
|
277
|
-
all(sql, ...params) {
|
|
278
|
-
return
|
|
279
|
-
raw.all(sql, ...params, (err, rows) => {
|
|
280
|
-
if (err)
|
|
281
|
-
return reject(err);
|
|
282
|
-
resolve(rows || []);
|
|
283
|
-
});
|
|
284
|
-
});
|
|
278
|
+
async all(sql, ...params) {
|
|
279
|
+
return prepare(sql).all(...params.map(bindable)).map(plain);
|
|
285
280
|
},
|
|
286
|
-
exec(sql) {
|
|
287
|
-
|
|
288
|
-
raw.exec(sql, (err) => {
|
|
289
|
-
if (err)
|
|
290
|
-
return reject(err);
|
|
291
|
-
resolve();
|
|
292
|
-
});
|
|
293
|
-
});
|
|
281
|
+
async exec(sql) {
|
|
282
|
+
raw.exec(sql);
|
|
294
283
|
},
|
|
295
|
-
close() {
|
|
296
|
-
|
|
297
|
-
raw.close((err) => {
|
|
298
|
-
if (err)
|
|
299
|
-
return reject(err);
|
|
300
|
-
resolve();
|
|
301
|
-
});
|
|
302
|
-
});
|
|
284
|
+
async close() {
|
|
285
|
+
raw.close();
|
|
303
286
|
},
|
|
304
287
|
async transaction(fn) {
|
|
305
288
|
await db.exec('BEGIN TRANSACTION');
|
|
@@ -318,22 +301,15 @@ function wrapDatabase(raw) {
|
|
|
318
301
|
}
|
|
319
302
|
/**
|
|
320
303
|
* Open (or create) the Rigour SQLite database.
|
|
321
|
-
* Returns null
|
|
304
|
+
* Returns null where this Node has no node:sqlite.
|
|
322
305
|
*/
|
|
323
306
|
export async function openDatabase(dbPath) {
|
|
324
|
-
const
|
|
325
|
-
if (!
|
|
307
|
+
const sqlite = loadSqlite();
|
|
308
|
+
if (!sqlite)
|
|
326
309
|
return null;
|
|
327
310
|
const resolvedPath = dbPath || DB_PATH;
|
|
328
311
|
fs.ensureDirSync(path.dirname(resolvedPath));
|
|
329
|
-
const
|
|
330
|
-
const instance = new sqlite3.Database(resolvedPath, (err) => {
|
|
331
|
-
if (err)
|
|
332
|
-
return reject(err);
|
|
333
|
-
resolve(instance);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
const db = wrapDatabase(raw);
|
|
312
|
+
const db = wrapDatabase(new sqlite.DatabaseSync(resolvedPath));
|
|
337
313
|
// Wait for a lock instead of failing at once: a hook and an MCP server often open the database
|
|
338
314
|
// at the same moment, and without this the second open fails with SQLITE_BUSY.
|
|
339
315
|
await db.exec('PRAGMA busy_timeout = 5000');
|
|
@@ -528,7 +504,7 @@ async function enableWal(db, attempts = 10) {
|
|
|
528
504
|
return;
|
|
529
505
|
}
|
|
530
506
|
catch (error) {
|
|
531
|
-
if (!/SQLITE_BUSY
|
|
507
|
+
if (!/SQLITE_BUSY|database is locked/i.test(String(error)) || attempt >= attempts)
|
|
532
508
|
throw error;
|
|
533
509
|
await new Promise(resolve => setTimeout(resolve, 25 * attempt));
|
|
534
510
|
}
|
|
@@ -555,8 +531,7 @@ async function addColumnIfMissing(db, table, column, alterSql) {
|
|
|
555
531
|
* Compact the database — prune old data, reclaim disk space.
|
|
556
532
|
*/
|
|
557
533
|
export async function compactDatabase(retainDays = 90) {
|
|
558
|
-
|
|
559
|
-
if (!sqlite3)
|
|
534
|
+
if (!loadSqlite())
|
|
560
535
|
return { pruned: 0, patternsDecayed: 0, sizeBefore: 0, sizeAfter: 0 };
|
|
561
536
|
const resolvedPath = DB_PATH;
|
|
562
537
|
const sizeBefore = fs.existsSync(resolvedPath) ? fs.statSync(resolvedPath).size : 0;
|
|
@@ -604,10 +579,8 @@ export function resetDatabase() {
|
|
|
604
579
|
if (fs.existsSync(DB_PATH + '-shm'))
|
|
605
580
|
fs.removeSync(DB_PATH + '-shm');
|
|
606
581
|
}
|
|
607
|
-
/**
|
|
608
|
-
* Check if SQLite is available (sqlite3 installed)
|
|
609
|
-
*/
|
|
582
|
+
/** Whether this Node has SQLite (node:sqlite, Node 22.13 or later). */
|
|
610
583
|
export function isSQLiteAvailable() {
|
|
611
|
-
return
|
|
584
|
+
return loadSqlite() !== null;
|
|
612
585
|
}
|
|
613
586
|
export { RIGOUR_DIR, DB_PATH };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { isSQLiteAvailable, openDatabase } from './db.js';
|
|
6
|
+
let dir;
|
|
7
|
+
let db;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-db-'));
|
|
10
|
+
db = (await openDatabase(path.join(dir, 'rigour.db')));
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await db?.close();
|
|
14
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe('the database (node:sqlite)', () => {
|
|
17
|
+
it('is built in: no package to install', () => {
|
|
18
|
+
expect(isSQLiteAvailable()).toBe(true);
|
|
19
|
+
expect(db).not.toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('stores what callers pass the way sqlite3 did: undefined as NULL, booleans as 1 or 0, a Date as epoch ms', async () => {
|
|
22
|
+
await db.exec('CREATE TABLE t (a, b, c, d)');
|
|
23
|
+
const when = new Date('2026-01-02T03:04:05Z');
|
|
24
|
+
const written = await db.run('INSERT INTO t VALUES (?, ?, ?, ?)', undefined, true, false, when);
|
|
25
|
+
expect(written).toEqual({ changes: 1, lastID: 1 });
|
|
26
|
+
const row = await db.get('SELECT * FROM t');
|
|
27
|
+
expect(row).toEqual({ a: null, b: 1, c: 0, d: when.getTime() });
|
|
28
|
+
expect(Object.getPrototypeOf(row)).toBe(Object.prototype); // a plain object, as callers spread and compare
|
|
29
|
+
expect(await db.get('SELECT * FROM t WHERE a = 1')).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
it('rolls a failed transaction back whole', async () => {
|
|
32
|
+
await db.exec('CREATE TABLE t (a UNIQUE)');
|
|
33
|
+
await expect(db.transaction(async (tx) => {
|
|
34
|
+
await tx.run('INSERT INTO t VALUES (?)', 1);
|
|
35
|
+
await tx.run('INSERT INTO t VALUES (?)', 1); // violates UNIQUE
|
|
36
|
+
})).rejects.toThrow(/UNIQUE/);
|
|
37
|
+
expect(await db.all('SELECT * FROM t')).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
it('opens an existing database again with its schema and data, in WAL mode', async () => {
|
|
40
|
+
await db.run("INSERT INTO meta (key, value) VALUES ('probe', 'kept')");
|
|
41
|
+
await db.close();
|
|
42
|
+
db = (await openDatabase(path.join(dir, 'rigour.db')));
|
|
43
|
+
expect(await db.get("SELECT value FROM meta WHERE key = 'probe'")).toEqual({ value: 'kept' });
|
|
44
|
+
expect(await db.get('PRAGMA journal_mode')).toEqual({ journal_mode: 'wal' });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -211,10 +211,12 @@ export const UNIVERSAL_CONFIG = {
|
|
|
211
211
|
unused_exports: {
|
|
212
212
|
enabled: true,
|
|
213
213
|
allow: [],
|
|
214
|
+
block: false,
|
|
214
215
|
},
|
|
215
216
|
orphan_files: {
|
|
216
217
|
enabled: true,
|
|
217
218
|
allow: [],
|
|
219
|
+
block: false,
|
|
218
220
|
},
|
|
219
221
|
query_patterns: {
|
|
220
222
|
enabled: true,
|
|
@@ -290,6 +292,7 @@ export const UNIVERSAL_CONFIG = {
|
|
|
290
292
|
judges: 2,
|
|
291
293
|
escalate: 'always',
|
|
292
294
|
cross_models: {},
|
|
295
|
+
judge_env: {},
|
|
293
296
|
},
|
|
294
297
|
},
|
|
295
298
|
ignore: [],
|