@rigour-labs/core 6.6.0 → 6.6.2

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.
@@ -1,4 +1,5 @@
1
1
  import type { RemovedBlock } from '../utils/diff.js';
2
+ import { type ReviewLesson } from '../review-learning/lessons.js';
2
3
  /** Kinds of operation where a wrong line costs the most; matched on the function's text. */
3
4
  export declare const SENSITIVE: ReadonlyArray<{
4
5
  kind: string;
@@ -14,6 +15,8 @@ export interface RiskSignals {
14
15
  removedGuard: boolean;
15
16
  /** SENSITIVE kinds this function touches. */
16
17
  sensitive: string[];
18
+ /** A verified team lesson about this file that names code this function uses. */
19
+ lesson?: string;
17
20
  }
18
21
  export interface FunctionRisk {
19
22
  file: string;
@@ -22,7 +25,7 @@ export interface FunctionRisk {
22
25
  end: number;
23
26
  signals: RiskSignals;
24
27
  score: number;
25
- /** sha256 of the function's text with whitespace collapsed: any edit to it changes this. */
28
+ /** sha256 of the function's exact text (line endings normalised): any edit to it changes this. */
26
29
  hash: string;
27
30
  }
28
31
  /**
@@ -30,8 +33,8 @@ export interface FunctionRisk {
30
33
  * "rules only". The router reviews functions at or above `min_score`.
31
34
  */
32
35
  export declare function scoreRisk(signals: RiskSignals): number;
33
- /** Changed functions across the change, riskiest first. */
34
- export declare function rankChangedFunctions(cwd: string, focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>): FunctionRisk[];
36
+ /** Changed functions outside tests, riskiest first; `lessons` are the team's verified review lessons in play. */
37
+ export declare function rankChangedFunctions(cwd: string, focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, lessons?: ReviewLesson[]): FunctionRisk[];
35
38
  /** The current text hash and span of a named function in a file, if the file parses and has it. */
36
39
  export declare function findFunction(cwd: string, file: string, name: string): {
37
40
  hash: string;
package/dist/deep/risk.js CHANGED
@@ -12,7 +12,10 @@ import fs from 'fs';
12
12
  import path from 'path';
13
13
  import ts from 'typescript';
14
14
  import { changedFunctions, isExported } from './changed-functions.js';
15
+ import { isSpecific } from '../review-learning/lessons.js';
15
16
  const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
17
+ /** Tests are reviewed with the change, not routed: their fixtures read as data writes and money, and their callbacks have no callers. */
18
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)__tests__\//i;
16
19
  /** Kinds of operation where a wrong line costs the most; matched on the function's text. */
17
20
  export const SENSITIVE = [
18
21
  { kind: 'data-write', pattern: /\.(?:insert|update|upsert|delete|rpc)\s*\(|\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO\s+)?\w|\bON CONFLICT\b/ },
@@ -28,7 +31,9 @@ export const SENSITIVE = [
28
31
  */
29
32
  export function scoreRisk(signals) {
30
33
  // Strong signals clear the default bar (1) on their own; shape alone (size, nesting) never does.
34
+ // A mistake this team already fixed once is as strong a signal as a removed guard.
31
35
  let score = signals.removedGuard ? 2 : 0;
36
+ score += signals.lesson ? 2 : 0;
32
37
  score += signals.sensitive.includes('data-write') ? 2 : 0;
33
38
  // Other sensitive kinds add, capped so a function that mentions everything does not drown the rest.
34
39
  score += Math.min(2, signals.sensitive.filter(kind => kind !== 'data-write').length);
@@ -37,11 +42,11 @@ export function scoreRisk(signals) {
37
42
  score += signals.exported && signals.async ? 0.5 : 0;
38
43
  return score;
39
44
  }
40
- /** Changed functions across the change, riskiest first. */
41
- export function rankChangedFunctions(cwd, focusLines, removed = {}) {
45
+ /** Changed functions outside tests, riskiest first; `lessons` are the team's verified review lessons in play. */
46
+ export function rankChangedFunctions(cwd, focusLines, removed = {}, lessons = []) {
42
47
  const ranked = [];
43
48
  for (const [file, lines] of Object.entries(focusLines)) {
44
- if (!PARSEABLE.test(file) || lines.length === 0)
49
+ if (!PARSEABLE.test(file) || TEST_FILE.test(file) || lines.length === 0)
45
50
  continue;
46
51
  let text;
47
52
  try {
@@ -57,6 +62,9 @@ export function rankChangedFunctions(cwd, focusLines, removed = {}) {
57
62
  const end = sourceFile.getLineAndCharacterOfPosition(fn.getEnd()).line + 1;
58
63
  const added = lines.filter(l => l >= start && l <= end).map(l => textLines[l - 1] ?? '');
59
64
  const signals = signalsOf(fn, sourceFile, start, end, removed[file] ?? [], added);
65
+ const lesson = lessonFor(file, fn.getText(sourceFile), lessons);
66
+ if (lesson)
67
+ signals.lesson = lesson;
60
68
  ranked.push({ file, name: nameOf(fn, sourceFile), start, end, signals, score: scoreRisk(signals), hash: functionHash(fn.getText(sourceFile)) });
61
69
  }
62
70
  }
@@ -101,6 +109,14 @@ export function findFunction(cwd, file, name) {
101
109
  export function functionHash(text) {
102
110
  return crypto.createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
103
111
  }
112
+ /** The first lesson about this file that names an identifier the function uses (only specific names link code). */
113
+ function lessonFor(file, body, lessons) {
114
+ const forFile = lessons.filter(l => l.file === file);
115
+ if (forFile.length === 0)
116
+ return undefined;
117
+ const used = new Set(body.match(/[A-Za-z_$][\w$]*/g) ?? []);
118
+ return forFile.find(l => l.symbols.some(s => isSpecific(s) && used.has(s)))?.text;
119
+ }
104
120
  const GUARD = /\b(?:if|else|return|throw|break|continue|catch)\b|\?\?|\?\./g;
105
121
  function guards(lines) {
106
122
  return lines.reduce((count, line) => count + (line.match(GUARD)?.length ?? 0), 0);
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
5
  import { functionHash, rankChangedFunctions, scoreRisk } from './risk.js';
6
- import { routeFiles } from './router.js';
6
+ import { DEFAULT_MIN_SCORE, routeFiles } from './router.js';
7
7
  let dir;
8
8
  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-')); });
9
9
  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
@@ -32,6 +32,33 @@ describe('rankChangedFunctions', () => {
32
32
  expect([byName.label.start, byName.label.end]).toEqual([8, 10]);
33
33
  });
34
34
  });
35
+ describe('test files', () => {
36
+ it('are never routed, however their fixtures read', () => {
37
+ fs.mkdirSync(path.join(dir, '__tests__'));
38
+ for (const file of ['sync.test.ts', 'sync.spec.js', '__tests__/sync.ts'])
39
+ fs.writeFileSync(path.join(dir, file), SYNC);
40
+ expect(rankChangedFunctions(dir, { 'sync.test.ts': [4], 'sync.spec.js': [4], '__tests__/sync.ts': [4] })).toEqual([]);
41
+ });
42
+ });
43
+ describe('team lessons as a risk signal', () => {
44
+ const lesson = (over) => ({
45
+ id: 'l1', text: 'Trim before comparing names.', file: 'sync.ts', symbols: ['trimStart'], state: 'verified',
46
+ evidence: [{ pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '', ...over,
47
+ });
48
+ const LABEL = 'function label(name) {\n return name.trimStart();\n}\n';
49
+ it('makes a plain function risky when a lesson for its file names a symbol it uses', () => {
50
+ fs.writeFileSync(path.join(dir, 'sync.ts'), LABEL);
51
+ const [f] = rankChangedFunctions(dir, { 'sync.ts': [2] }, {}, [lesson({})]);
52
+ expect(f.signals.lesson).toBe('Trim before comparing names.');
53
+ expect(f.score).toBeGreaterThanOrEqual(DEFAULT_MIN_SCORE);
54
+ });
55
+ it('ignores lessons for other files and lessons naming only common words', () => {
56
+ fs.writeFileSync(path.join(dir, 'sync.ts'), LABEL);
57
+ const other = rankChangedFunctions(dir, { 'sync.ts': [2] }, {}, [lesson({ file: 'other.ts' }), lesson({ symbols: ['name'] })]);
58
+ expect(other[0].signals.lesson).toBeUndefined();
59
+ expect(other[0].score).toBe(0);
60
+ });
61
+ });
35
62
  describe('scoreRisk', () => {
36
63
  it('ranks a data write behind a removed guard above a plain helper', () => {
37
64
  const risky = { exported: true, async: true, nesting: 2, lines: 30, removedGuard: true, sensitive: ['data-write', 'paging'] };
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { RemovedBlock } from '../utils/diff.js';
9
9
  import { type ReviewedKey } from '../review/ledger.js';
10
+ import type { ReviewLesson } from '../review-learning/lessons.js';
10
11
  export interface RouterPolicy {
11
12
  enabled?: boolean;
12
13
  min_score?: number;
@@ -21,7 +22,7 @@ export interface RouterStats {
21
22
  }
22
23
  export declare const DEFAULT_MIN_SCORE = 1;
23
24
  export declare const DEFAULT_MAX_FUNCTIONS = 12;
24
- export declare function routeFiles(cwd: string, files: string[], focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, policy?: RouterPolicy, reviewed?: ReviewedKey[]): {
25
+ export declare function routeFiles(cwd: string, files: string[], focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, policy?: RouterPolicy, reviewed?: ReviewedKey[], lessons?: ReviewLesson[]): {
25
26
  files: Set<string>;
26
27
  stats: RouterStats;
27
28
  };
@@ -2,9 +2,9 @@ import { rankChangedFunctions } from './risk.js';
2
2
  import { isReviewed } from '../review/ledger.js';
3
3
  export const DEFAULT_MIN_SCORE = 1;
4
4
  export const DEFAULT_MAX_FUNCTIONS = 12;
5
- export function routeFiles(cwd, files, focusLines, removed = {}, policy = {}, reviewed = []) {
5
+ export function routeFiles(cwd, files, focusLines, removed = {}, policy = {}, reviewed = [], lessons = []) {
6
6
  const inScope = Object.fromEntries(files.filter(f => focusLines[f]?.length).map(f => [f, focusLines[f]]));
7
- const ranked = rankChangedFunctions(cwd, inScope, removed);
7
+ const ranked = rankChangedFunctions(cwd, inScope, removed, lessons);
8
8
  const minScore = policy.min_score ?? DEFAULT_MIN_SCORE;
9
9
  const risky = ranked.filter(f => f.score >= minScore);
10
10
  const unreviewed = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
@@ -23,7 +23,7 @@ import { buildReviewTask } from '../review/review-task.js';
23
23
  import { reviewPullRequest } from '../deep/pr-review.js';
24
24
  import { relatedChanges } from '../deep/related-changes.js';
25
25
  import { rankChangedFunctions } from '../deep/risk.js';
26
- import { lessonsForDiff, lessonsSection } from '../review-learning/team-lessons.js';
26
+ import { activeLessons, lessonsForDiff, lessonsSection } from '../review-learning/team-lessons.js';
27
27
  import { rulesForDiff, rulesSection } from '../review-learning/repo-rules.js';
28
28
  import { verifyCodeFindings } from '../deep/code-verifier.js';
29
29
  import { runIntentChecks } from './deep-intent.js';
@@ -153,7 +153,7 @@ export class DeepAnalysisGate extends Gate {
153
153
  this.config.onProgress?.(' Router: no risky change for the model; the gates cover this PR.');
154
154
  return [];
155
155
  }
156
- const focus = buildReviewTask(cwd, options.diff, this.config.router).items;
156
+ const focus = buildReviewTask(cwd, options.diff, this.config.router, this.config.reviewLessons, false, trustedReviews(cwd, options)).items;
157
157
  const changedFiles = Object.keys(options.focusLines ?? {});
158
158
  const related = relatedChanges(cwd, changedFiles, rankChangedFunctions(cwd, options.focusLines ?? {}, options.removedLines));
159
159
  this.config.onProgress?.(` Reviewing the PR as a whole (${focus.length} risky function(s) first)...`);
@@ -179,7 +179,7 @@ export class DeepAnalysisGate extends Gate {
179
179
  const { options } = this.config;
180
180
  if (!isCloud(options) || this.config.router?.enabled === false || !options.focusLines)
181
181
  return facts;
182
- const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router, trustedReviews(cwd, options));
182
+ const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router, trustedReviews(cwd, options), activeLessons(cwd, this.config.reviewLessons));
183
183
  this.outcome.router = routed.stats;
184
184
  if (routed.stats.files_skipped > 0) {
185
185
  this.config.onProgress?.(` Router: ${routed.stats.routed} of ${routed.stats.functions} changed function(s) to the model; ${routed.stats.files_skipped} file(s) left to the gates.`);
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMIS
19
19
  export { isGeneratedFile } from './review/generated-files.js';
20
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
21
  export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
22
+ export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
22
23
  export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
23
24
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
24
25
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type FunctionRisk, type RiskSignals } from './deep/risk.js';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMIS
19
19
  export { isGeneratedFile } from './review/generated-files.js';
20
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
21
  export { buildReviewTask } from './review/review-task.js';
22
+ export { buildQualityReceipt, receiptReport } from './review/receipt.js';
22
23
  export { acknowledgeReview } from './review/review-ack.js';
23
24
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
24
25
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './deep/risk.js';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The quality receipt for a change: for every changed function outside tests, what is known about
3
+ * it before a model looks at anything.
4
+ *
5
+ * - reviewed: risky, and reviewed at its current code during development (the ledger or a
6
+ * committed reviewed.json, keyed by the function's exact source);
7
+ * - changed since review: risky, reviewed once, then edited, so that review no longer counts;
8
+ * - low risk: below the router's bar, left to the deterministic checks;
9
+ * - not covered: risky with no review of this code. These are what a PR review should spend on.
10
+ *
11
+ * Reviews are agents' and people's own records. An independent (enforcing) review does not count
12
+ * them, and the receipt says how many it set aside rather than hiding them.
13
+ */
14
+ import { type RouterPolicy } from '../deep/router.js';
15
+ import { type LessonMode } from '../review-learning/team-lessons.js';
16
+ export interface ReceiptGap {
17
+ file: string;
18
+ function: string;
19
+ start: number;
20
+ score: number;
21
+ /** Reviewed once, then edited. */
22
+ changedSinceReview: boolean;
23
+ /** The team lesson that made it risky, when one did. */
24
+ lesson?: string;
25
+ }
26
+ export interface QualityReceipt {
27
+ functions: number;
28
+ reviewed: number;
29
+ changedSinceReview: number;
30
+ lowRisk: number;
31
+ notCovered: ReceiptGap[];
32
+ /** Who reviewed the functions counted as reviewed: agent, human, or byok:<model>. */
33
+ reviewers: Record<string, number>;
34
+ /** Changed files Rigour cannot split into functions (other languages, config): rules only. */
35
+ otherFiles: number;
36
+ /** Self-reported reviews an independent review did not count. */
37
+ setAside: number;
38
+ }
39
+ export interface ReceiptOptions {
40
+ policy?: RouterPolicy;
41
+ lessonMode?: LessonMode;
42
+ /** Trust nothing the change itself recorded (enforce mode). */
43
+ independent?: boolean;
44
+ }
45
+ export declare function buildQualityReceipt(cwd: string, diff: string, options?: ReceiptOptions): QualityReceipt;
46
+ /** The receipt as reports carry it (snake_case, like the rest of `rigour review --json` and the MCP tool). */
47
+ export declare function receiptReport(r: QualityReceipt): {
48
+ functions: number;
49
+ reviewed: number;
50
+ changed_since_review: number;
51
+ low_risk: number;
52
+ not_covered: {
53
+ lesson?: string | undefined;
54
+ file: string;
55
+ function: string;
56
+ line: number;
57
+ score: number;
58
+ changed_since_review: boolean;
59
+ }[];
60
+ reviewers: Record<string, number>;
61
+ other_files: number;
62
+ set_aside: number;
63
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The quality receipt for a change: for every changed function outside tests, what is known about
3
+ * it before a model looks at anything.
4
+ *
5
+ * - reviewed: risky, and reviewed at its current code during development (the ledger or a
6
+ * committed reviewed.json, keyed by the function's exact source);
7
+ * - changed since review: risky, reviewed once, then edited, so that review no longer counts;
8
+ * - low risk: below the router's bar, left to the deterministic checks;
9
+ * - not covered: risky with no review of this code. These are what a PR review should spend on.
10
+ *
11
+ * Reviews are agents' and people's own records. An independent (enforcing) review does not count
12
+ * them, and the receipt says how many it set aside rather than hiding them.
13
+ */
14
+ import { DEFAULT_MIN_SCORE } from '../deep/router.js';
15
+ import { rankChangedFunctions } from '../deep/risk.js';
16
+ import { activeLessons, DEFAULT_LESSON_MODE } from '../review-learning/team-lessons.js';
17
+ import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
18
+ import { isReviewed, readLedger, reviewedKeys } from './ledger.js';
19
+ const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
20
+ export function buildQualityReceipt(cwd, diff, options = {}) {
21
+ const changed = changedLinesByFile(parseDiff(diff));
22
+ const ranked = rankChangedFunctions(cwd, changed, removedByFile(diff), activeLessons(cwd, options.lessonMode ?? DEFAULT_LESSON_MODE));
23
+ const minScore = options.policy?.min_score ?? DEFAULT_MIN_SCORE;
24
+ const risky = ranked.filter(f => f.score >= minScore);
25
+ const recorded = reviewedKeys(cwd);
26
+ const trusted = options.independent ? [] : recorded;
27
+ const reviewedNow = risky.filter(f => isReviewed(trusted, keyOf(f)));
28
+ const gaps = risky.filter(f => !isReviewed(trusted, keyOf(f)));
29
+ return {
30
+ functions: ranked.length,
31
+ reviewed: reviewedNow.length,
32
+ changedSinceReview: gaps.filter(f => reviewedBefore(recorded, f)).length,
33
+ lowRisk: ranked.length - risky.length,
34
+ notCovered: gaps.map(f => ({
35
+ file: f.file, function: f.name, start: f.start, score: f.score,
36
+ changedSinceReview: reviewedBefore(recorded, f),
37
+ ...(f.signals.lesson ? { lesson: f.signals.lesson } : {}),
38
+ })),
39
+ reviewers: reviewersOf(cwd, reviewedNow),
40
+ otherFiles: Object.keys(changed).filter(file => !PARSEABLE.test(file)).length,
41
+ setAside: options.independent ? risky.filter(f => isReviewed(recorded, keyOf(f))).length : 0,
42
+ };
43
+ }
44
+ function keyOf(f) {
45
+ return { file: f.file, function: f.name, hash: f.hash };
46
+ }
47
+ /** Some version of this function was reviewed, just not the code it has now. */
48
+ function reviewedBefore(recorded, f) {
49
+ return recorded.some(r => r.file === f.file && r.function === f.name && r.hash !== f.hash);
50
+ }
51
+ function reviewersOf(cwd, reviewed) {
52
+ const ledger = readLedger(cwd);
53
+ const counts = {};
54
+ for (const f of reviewed) {
55
+ const entry = [...ledger].reverse().find(e => e.file === f.file && e.function === f.name && e.hash === f.hash);
56
+ const who = entry ? (entry.reviewer.startsWith('byok:') ? 'model' : entry.reviewer) : 'recorded';
57
+ counts[who] = (counts[who] ?? 0) + 1;
58
+ }
59
+ return counts;
60
+ }
61
+ /** The receipt as reports carry it (snake_case, like the rest of `rigour review --json` and the MCP tool). */
62
+ export function receiptReport(r) {
63
+ return {
64
+ functions: r.functions,
65
+ reviewed: r.reviewed,
66
+ changed_since_review: r.changedSinceReview,
67
+ low_risk: r.lowRisk,
68
+ not_covered: r.notCovered.map(g => ({ file: g.file, function: g.function, line: g.start, score: g.score, changed_since_review: g.changedSinceReview, ...(g.lesson ? { lesson: g.lesson } : {}) })),
69
+ reviewers: r.reviewers,
70
+ other_files: r.otherFiles,
71
+ set_aside: r.setAside,
72
+ };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { findFunction } from '../deep/risk.js';
7
+ import { diffFromGit } from './git-diff.js';
8
+ import { recordReview } from './ledger.js';
9
+ import { buildQualityReceipt } from './receipt.js';
10
+ let repo;
11
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
12
+ const write = (rel, body) => { fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true }); fs.writeFileSync(path.join(repo, rel), body); };
13
+ const review = (file, fn, reviewer = 'agent') => {
14
+ const found = findFunction(repo, file, fn);
15
+ recordReview(repo, { file, function: fn, hash: found.hash, reviewer, verdict: 'no_issue', note: 'checked' });
16
+ };
17
+ beforeEach(() => {
18
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-'));
19
+ git('init', '-q', '-b', 'main');
20
+ git('config', 'user.email', 't@example.com');
21
+ git('config', 'user.name', 't');
22
+ git('config', 'commit.gpgsign', 'false');
23
+ write('.gitignore', '.rigour/\n');
24
+ write('src/pay.ts', 'export const x = 1;\n');
25
+ git('add', '-A');
26
+ git('commit', '-qm', 'init');
27
+ });
28
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
29
+ // Risky: a data write. Low risk: a formatter. Each in its own function.
30
+ const RISKY = (n) => `export async function saveRefund${n}(db: any, cents: number) {\n return db.insert({ cents: cents + ${n} });\n}\n`;
31
+ const PLAIN = 'export function label(name: string) {\n return name.trim();\n}\n';
32
+ describe('buildQualityReceipt', () => {
33
+ it('says what was reviewed at its current code, what changed after review, what is low risk, and what is not covered', () => {
34
+ write('src/pay.ts', RISKY(1) + RISKY(2) + RISKY(3) + PLAIN);
35
+ write('config/app.yml', 'a: 1\n');
36
+ review('src/pay.ts', 'saveRefund1');
37
+ review('src/pay.ts', 'saveRefund2');
38
+ write('src/pay.ts', RISKY(1) + RISKY(2).replace('cents + 2', 'cents + 20') + RISKY(3) + PLAIN); // edited after its review
39
+ const receipt = buildQualityReceipt(repo, diffFromGit(repo));
40
+ expect(receipt).toMatchObject({ functions: 4, reviewed: 1, changedSinceReview: 1, lowRisk: 1, otherFiles: 1, setAside: 0, reviewers: { agent: 1 } });
41
+ expect(receipt.notCovered.map(g => [g.function, g.changedSinceReview])).toEqual([['saveRefund2', true], ['saveRefund3', false]]);
42
+ });
43
+ it('counts no self-reported review when independent, and says how many it set aside', () => {
44
+ write('src/pay.ts', RISKY(1) + RISKY(2));
45
+ review('src/pay.ts', 'saveRefund1');
46
+ const receipt = buildQualityReceipt(repo, diffFromGit(repo), { independent: true });
47
+ expect(receipt).toMatchObject({ reviewed: 0, setAside: 1 });
48
+ expect(receipt.notCovered).toHaveLength(2);
49
+ });
50
+ it('treats a function matching a verified team lesson as risky, and says which lesson', () => {
51
+ write('src/pay.ts', 'export function formatTotal(totalCents: number) {\n return (totalCents / 100).toFixed(2);\n}\n');
52
+ write('.rigour/review-lessons.json', JSON.stringify({ version: 1, lessons: [{
53
+ id: 'l1', text: 'Format money from integer cents, never floats.', file: 'src/pay.ts', symbols: ['totalCents'],
54
+ state: 'verified', evidence: [{ pr: 12, comment: 'c', author: 'reviewer' }], createdAt: '', updatedAt: '',
55
+ }] }));
56
+ const withLesson = buildQualityReceipt(repo, diffFromGit(repo));
57
+ expect(withLesson.notCovered).toEqual([expect.objectContaining({ function: 'formatTotal', lesson: 'Format money from integer cents, never floats.' })]);
58
+ expect(buildQualityReceipt(repo, diffFromGit(repo), { lessonMode: 'off' }).notCovered).toEqual([]);
59
+ });
60
+ });
@@ -9,6 +9,7 @@
9
9
  * pre-empted four times as many acted-on review comments as chunked prompts.
10
10
  */
11
11
  import { type RouterPolicy } from '../deep/router.js';
12
+ import { type ReviewedKey } from './ledger.js';
12
13
  import { type LessonMode } from '../review-learning/team-lessons.js';
13
14
  export interface ReviewTaskItem {
14
15
  file: string;
@@ -37,4 +38,5 @@ export interface ReviewTask {
37
38
  }>;
38
39
  instructions: string;
39
40
  }
40
- export declare function buildReviewTask(cwd: string, diff: string, policy?: RouterPolicy, lessonMode?: LessonMode, repoRules?: boolean): ReviewTask;
41
+ /** `reviewed`: the reviews to trust; an independent (enforcing) review passes none, so self-reported reviews skip nothing. */
42
+ export declare function buildReviewTask(cwd: string, diff: string, policy?: RouterPolicy, lessonMode?: LessonMode, repoRules?: boolean, reviewed?: ReviewedKey[]): ReviewTask;
@@ -12,7 +12,7 @@ import { DEFAULT_MAX_FUNCTIONS, DEFAULT_MIN_SCORE } from '../deep/router.js';
12
12
  import { rankChangedFunctions } from '../deep/risk.js';
13
13
  import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
14
14
  import { isReviewed, reviewedKeys } from './ledger.js';
15
- import { lessonsForDiff } from '../review-learning/team-lessons.js';
15
+ import { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff } from '../review-learning/team-lessons.js';
16
16
  import { rulesForDiff } from '../review-learning/repo-rules.js';
17
17
  const QUESTIONS = {
18
18
  'data-write': 'Is the write safe under concurrent calls (no check-then-act), idempotent on retry, and does an upsert/conflict clause do what the caller expects?',
@@ -22,11 +22,11 @@ const QUESTIONS = {
22
22
  concurrency: 'What happens if two calls overlap, or one step fails half-way through?',
23
23
  network: 'Are timeouts, aborts and non-2xx responses handled, and does a retry repeat a side effect?',
24
24
  };
25
- export function buildReviewTask(cwd, diff, policy = {}, lessonMode = 'off', repoRules = false) {
25
+ /** `reviewed`: the reviews to trust; an independent (enforcing) review passes none, so self-reported reviews skip nothing. */
26
+ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = DEFAULT_LESSON_MODE, repoRules = false, reviewed = reviewedKeys(cwd)) {
26
27
  const changed = parseDiff(diff);
27
- const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff));
28
+ const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff), activeLessons(cwd, lessonMode));
28
29
  const risky = ranked.filter(f => f.score >= (policy.min_score ?? DEFAULT_MIN_SCORE)).slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
29
- const reviewed = reviewedKeys(cwd);
30
30
  const pending = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
31
31
  const lessons = lessonsForDiff(cwd, diff, lessonMode).map(l => ({ file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))] }));
32
32
  return {
@@ -42,6 +42,7 @@ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = 'off', repo
42
42
  }
43
43
  function toItem(f) {
44
44
  const questions = [
45
+ ...(f.signals.lesson ? [`Your team flagged this before: "${f.signals.lesson}" Does this change repeat it?`] : []),
45
46
  ...(f.signals.removedGuard ? ['The change removed a condition, return or throw here: which inputs did it guard against, and are they still handled?'] : []),
46
47
  ...f.signals.sensitive.map(kind => QUESTIONS[kind]).filter(Boolean),
47
48
  `Do the callers of \`${f.name}\` still get what they assume (types, empty and error cases)?`,
@@ -8,6 +8,7 @@ import { acknowledgeReview } from './review-ack.js';
8
8
  import { buildReviewTask } from './review-task.js';
9
9
  import { exportReviewed, readLedger, REVIEWED_FILE } from './ledger.js';
10
10
  import { reviewAckMessage } from '../hooks/stop-review.js';
11
+ import { writeLessons } from '../review-learning/lessons.js';
11
12
  let repo;
12
13
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
13
14
  const write = (body) => fs.writeFileSync(path.join(repo, 'sync.ts'), body);
@@ -51,6 +52,14 @@ describe('review task and acknowledgements', () => {
51
52
  expect(task.items[0].questions[0]).toMatch(/concurrent calls/);
52
53
  expect(task.items[0].questions.at(-1)).toContain('`syncOrders`');
53
54
  });
55
+ it('sends a function a verified team lesson names to review, and asks whether the change repeats it', () => {
56
+ write(AFTER.replace('name.trim()', 'name.toLocaleUpperCase()'));
57
+ writeLessons(repo, [{ id: 'l1', text: 'Locale casing breaks Turkish names.', file: 'sync.ts', symbols: ['toLocaleUpperCase'], state: 'verified', evidence: [{ pr: 7, comment: 'c1', author: 'r' }], createdAt: '', updatedAt: '' }]);
58
+ const task = buildReviewTask(repo, diffFromGit(repo));
59
+ const label = task.items.find(i => i.function === 'label');
60
+ expect(label?.questions[0]).toContain('Locale casing breaks Turkish names.');
61
+ expect(buildReviewTask(repo, diffFromGit(repo), {}, 'off').items.map(i => i.function)).toEqual(['syncOrders']);
62
+ });
54
63
  it('drops an acknowledged function from the task until its code changes again', () => {
55
64
  const ack = acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'no_issue', note: 'upsert key is the unique order id; callers retry safely' });
56
65
  expect(ack.ok).toBe(true);
@@ -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
- export function lessonsForDiff(cwd, diff, mode = 'off') {
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);
@@ -44,8 +44,10 @@ export function setTelemetryEnabled(enabled, home = os.homedir()) {
44
44
  writeJson(file(home, 'telemetry.json'), state);
45
45
  return state;
46
46
  }
47
+ /** CI systems by the variable each always sets; Jenkins, Azure Pipelines and TeamCity do not set CI. */
48
+ const CI_MARKERS = ['CI', 'GITHUB_ACTIONS', 'BUILDKITE', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'TF_BUILD', 'TEAMCITY_VERSION'];
47
49
  function isCi(env) {
48
- return !!(env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.GITLAB_CI || env.CIRCLECI);
50
+ return CI_MARKERS.some(name => !!env[name]);
49
51
  }
50
52
  /** Opted out by the environment, whatever the stored answer. */
51
53
  function vetoed(env) {
@@ -22,6 +22,11 @@ describe('telemetry consent', () => {
22
22
  it('never asks in CI or without a terminal, and keeps one random install id', () => {
23
23
  expect(shouldAskTelemetry(false, { env, home })).toBe(false);
24
24
  expect(shouldAskTelemetry(true, { env: { ...env, GITHUB_ACTIONS: 'true' }, home })).toBe(false);
25
+ setTelemetryEnabled(true, home);
26
+ for (const ci of ['JENKINS_URL', 'TF_BUILD', 'TEAMCITY_VERSION']) {
27
+ expect(isTelemetryEnabled({ env: { ...env, [ci]: 'x' }, home })).toBe(false); // a build agent's stored yes is not a CI opt-in
28
+ expect(isTelemetryEnabled({ env: { ...env, [ci]: 'x', RIGOUR_TELEMETRY: '1' }, home })).toBe(true);
29
+ }
25
30
  const first = setTelemetryEnabled(false, home).installId;
26
31
  expect(readTelemetryState(home).installId).toBe(first);
27
32
  expect(first).toMatch(/^[0-9a-f-]{36}$/);
@@ -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: off (default), verified, or all
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.6.0",
3
+ "version": "6.6.2",
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-arm64": "6.6.0",
74
- "@rigour-labs/brain-darwin-x64": "6.6.0",
75
- "@rigour-labs/brain-linux-arm64": "6.6.0",
76
- "@rigour-labs/brain-linux-x64": "6.6.0",
77
- "@rigour-labs/brain-win-x64": "6.6.0"
73
+ "@rigour-labs/brain-darwin-x64": "6.6.2",
74
+ "@rigour-labs/brain-linux-x64": "6.6.2",
75
+ "@rigour-labs/brain-linux-arm64": "6.6.2",
76
+ "@rigour-labs/brain-darwin-arm64": "6.6.2",
77
+ "@rigour-labs/brain-win-x64": "6.6.2"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/fs-extra": "^11.0.4",