@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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/dist/hooks/index.d.ts +3 -3
  3. package/dist/hooks/index.js +2 -2
  4. package/dist/hooks/session-state.d.ts +3 -0
  5. package/dist/hooks/session-state.js +10 -0
  6. package/dist/hooks/stop-review.d.ts +8 -15
  7. package/dist/hooks/stop-review.js +36 -2
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +4 -2
  10. package/dist/review/backtest-init.js +13 -11
  11. package/dist/review/backtest-init.test.js +26 -2
  12. package/dist/review/backtest.js +35 -3
  13. package/dist/review/backtest.test.js +44 -2
  14. package/dist/review/dead-code.test.js +13 -0
  15. package/dist/review/orphan-files.js +3 -2
  16. package/dist/review/quiet.d.ts +1 -1
  17. package/dist/review/quiet.js +3 -1
  18. package/dist/review/review-task.d.ts +2 -6
  19. package/dist/review/review-task.js +2 -2
  20. package/dist/review/review.js +19 -4
  21. package/dist/review/review.test.js +11 -0
  22. package/dist/review/reviewer/adapters.d.ts +21 -0
  23. package/dist/review/reviewer/adapters.js +70 -15
  24. package/dist/review/reviewer/adapters.test.js +22 -0
  25. package/dist/review/reviewer/background.test.js +1 -1
  26. package/dist/review/reviewer/context.d.ts +3 -0
  27. package/dist/review/reviewer/context.js +14 -5
  28. package/dist/review/reviewer/context.test.js +26 -1
  29. package/dist/review/reviewer/exec.d.ts +9 -0
  30. package/dist/review/reviewer/exec.js +28 -1
  31. package/dist/review/reviewer/exec.test.js +38 -2
  32. package/dist/review/reviewer/inputs.d.ts +6 -0
  33. package/dist/review/reviewer/inputs.js +26 -0
  34. package/dist/review/reviewer/judge-env.d.ts +3 -0
  35. package/dist/review/reviewer/judge-env.js +10 -0
  36. package/dist/review/reviewer/prompt.js +68 -12
  37. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  38. package/dist/review/reviewer/rule-writer.js +30 -0
  39. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  40. package/dist/review/reviewer/rule-writer.test.js +52 -0
  41. package/dist/review/reviewer/settings.d.ts +4 -0
  42. package/dist/review/reviewer/settings.js +2 -1
  43. package/dist/review/reviewer/verdict.d.ts +71 -9
  44. package/dist/review/reviewer/verdict.js +102 -10
  45. package/dist/review/reviewer.d.ts +5 -2
  46. package/dist/review/reviewer.js +56 -41
  47. package/dist/review/reviewer.test.js +170 -17
  48. package/dist/review/typed/constant-inputs.d.ts +35 -0
  49. package/dist/review/typed/constant-inputs.js +168 -0
  50. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  51. package/dist/review/typed/dead-null-guards.js +147 -0
  52. package/dist/review/typed/redundancy.js +6 -0
  53. package/dist/review/typed/redundancy.test.js +136 -0
  54. package/dist/review/unused-exports.js +3 -2
  55. package/dist/review-learning/acted-on.d.ts +23 -3
  56. package/dist/review-learning/acted-on.js +25 -13
  57. package/dist/review-learning/human-edits.d.ts +7 -0
  58. package/dist/review-learning/human-edits.js +99 -0
  59. package/dist/review-learning/human-edits.test.d.ts +1 -0
  60. package/dist/review-learning/human-edits.test.js +47 -0
  61. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  62. package/dist/review-learning/learn-from-reviews.js +128 -12
  63. package/dist/review-learning/lessons.d.ts +62 -9
  64. package/dist/review-learning/lessons.js +122 -20
  65. package/dist/review-learning/outcomes.d.ts +26 -0
  66. package/dist/review-learning/outcomes.js +73 -0
  67. package/dist/review-learning/review-learning.test.js +149 -22
  68. package/dist/review-learning/review-points.d.ts +4 -0
  69. package/dist/review-learning/review-points.js +14 -0
  70. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  71. package/dist/review-learning/rules-from-reviews.js +86 -0
  72. package/dist/review-learning/team-lessons.d.ts +17 -1
  73. package/dist/review-learning/team-lessons.js +29 -5
  74. package/dist/services/temporal-drift.js +1 -1
  75. package/dist/storage/db.d.ts +4 -6
  76. package/dist/storage/db.js +63 -90
  77. package/dist/storage/db.test.d.ts +1 -0
  78. package/dist/storage/db.test.js +46 -0
  79. package/dist/templates/universal-config.js +3 -0
  80. package/dist/types/index.d.ts +63 -0
  81. package/dist/types/index.js +11 -0
  82. package/dist/utils/profile.d.ts +5 -1
  83. package/dist/utils/profile.js +7 -1
  84. package/dist/utils/profile.test.js +10 -0
  85. 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
- export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode): ReviewLesson[];
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
- export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE) {
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
- const files = Object.keys(parseDiff(diff));
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
- const symbols = new Set(added.match(/[A-Za-z_$][\w$]{3,}/g) ?? []);
25
- return matchLessons(lessons, { files, symbols }, { includeCandidates: mode === 'all' });
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 => `- ${l.file}: ${l.text} (acted on in PR ${[...new Set(l.evidence.map(e => `#${e.pr}`))].join(', ')})`);
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 — install sqlite3 to enable scan history');
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
  }
@@ -1,8 +1,8 @@
1
1
  declare const RIGOUR_DIR: string;
2
2
  declare const DB_PATH: string;
3
3
  /**
4
- * Promisified wrapper around a node-sqlite3 Database instance.
5
- * Provides run/get/all/exec methods that return Promises.
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 if sqlite3 is not available.
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 };
@@ -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 node-sqlite3 (async, widely supported, no native build issues).
7
- * All public APIs are async. Graceful degradation if sqlite3 not installed.
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
- // Optional dynamic import of sqlite3
15
- // ---------------------------------------------------------------------------
16
- let sqlite3Module = null;
17
- let _resolved = false;
18
- function loadSqlite3() {
19
- if (_resolved)
20
- return sqlite3Module;
21
- _resolved = true;
22
- // Try multiple resolution paths:
23
- // 1. Relative to this package (works when sqlite3 is installed in monorepo)
24
- // 2. Relative to cwd (works when user installs sqlite3 in their project)
25
- // 3. Relative to global node_modules (works for global installs)
26
- const searchPaths = [
27
- import.meta.url,
28
- `file://${process.cwd()}/package.json`,
29
- `file://${path.join(rigourUserDir(), 'package.json')}`,
30
- ];
31
- for (const base of searchPaths) {
32
- try {
33
- const req = createRequire(base);
34
- sqlite3Module = req('sqlite3');
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
- sqlite3Module = null;
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
- return new Promise((resolve, reject) => {
261
- raw.run(sql, ...params, function (err) {
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
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
- return new Promise((resolve, reject) => {
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
- return new Promise((resolve, reject) => {
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 if sqlite3 is not available.
304
+ * Returns null where this Node has no node:sqlite.
322
305
  */
323
306
  export async function openDatabase(dbPath) {
324
- const sqlite3 = loadSqlite3();
325
- if (!sqlite3)
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 raw = await new Promise((resolve, reject) => {
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/.test(String(error)) || attempt >= attempts)
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
- const sqlite3 = loadSqlite3();
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 loadSqlite3() !== null;
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: [],