@rigour-labs/core 6.7.2 → 6.7.5

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 (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +132 -21
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +158 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -13,6 +13,6 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
13
13
  export type { DLPFeedbackEntry, DLPFeedbackStore, DLPBlockManifest } from './dlp-feedback.js';
14
14
  export { generateDLPHookFiles } from './dlp-templates.js';
15
15
  export type { GeneratedDLPHookFile } from './dlp-templates.js';
16
- export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
17
- export type { StopDecision } from './stop-review.js';
18
- export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
16
+ export { stopReview, stopMessage, teamMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
17
+ export type { StopDecision, TeamGuidance } from './stop-review.js';
18
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed, untaught, recordTaught } from './session-state.js';
@@ -9,5 +9,5 @@ export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './
9
9
  export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
10
10
  export { generateDLPHookFiles } from './dlp-templates.js';
11
11
  // Stop review ("before you say done")
12
- export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
13
- export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
12
+ export { stopReview, stopMessage, teamMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
13
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed, untaught, recordTaught } from './session-state.js';
@@ -15,3 +15,6 @@ export declare function recordReviewed(cwd: string, session: string, fingerprint
15
15
  export declare function nextStopAttempt(cwd: string, session: string): number;
16
16
  /** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
17
17
  export declare function clearStopAttempts(cwd: string, session: string): void;
18
+ /** Of these team-knowledge keys, the ones this session has not been shown yet. */
19
+ export declare function untaught(cwd: string, session: string, keys: string[]): string[];
20
+ export declare function recordTaught(cwd: string, session: string, keys: string[]): void;
@@ -4,6 +4,7 @@
4
4
  * - baseline: the commit HEAD was at when the session first edited a file. The stop review
5
5
  * covers everything since then, so committing mid-session hides nothing.
6
6
  * - attempts: how many times the stop review has blocked this session.
7
+ * - taught: the team lessons and rules the stop review already put to the agent, so each is asked once.
7
8
  */
8
9
  import { spawnSync } from 'child_process';
9
10
  import { createHash } from 'crypto';
@@ -96,3 +97,12 @@ export function clearStopAttempts(cwd, session) {
96
97
  if (readStore(cwd)[session]?.attempts)
97
98
  update(cwd, session, entry => ({ ...entry, attempts: 0 }));
98
99
  }
100
+ /** Of these team-knowledge keys, the ones this session has not been shown yet. */
101
+ export function untaught(cwd, session, keys) {
102
+ const taught = new Set(readStore(cwd)[session]?.taught ?? []);
103
+ return keys.filter(key => !taught.has(key));
104
+ }
105
+ export function recordTaught(cwd, session, keys) {
106
+ if (keys.length)
107
+ update(cwd, session, entry => ({ ...entry, taught: [...new Set([...(entry.taught ?? []), ...keys])] }));
108
+ }
@@ -1,18 +1,3 @@
1
- /**
2
- * The "before you say done" review: when an agent is about to stop, review the
3
- * whole branch against the commit it left main at (committed, uncommitted and new
4
- * files alike; on main itself, what the session changed since its baseline) and,
5
- * if it introduced serious findings, tell the agent what to fix instead of
6
- * letting it finish. A review with nothing to look at says so; it is never
7
- * reported as a clean pass.
8
- *
9
- * What blocks is what the review itself reports (review/quiet.ts `mustFix`: proven, critical, or
10
- * high and verified or from a security gate), plus on a branch a merge conflict with main or a
11
- * mention of a file the branch deleted (review/branch-checks.ts). The same rule decides the push
12
- * gate, so a stop, a review and a push never disagree about a finding. A high heuristic (a regex
13
- * that sees `fetch` without `.catch`) is not enough to hold an agent back; callers also cap the
14
- * number of attempts.
15
- */
16
1
  import type { Config, Failure } from '../types/index.js';
17
2
  import { type ReviewTaskItem } from '../review/review-task.js';
18
3
  export declare const STOP_MAX_ATTEMPTS = 3;
@@ -26,8 +11,16 @@ export interface StopDecision {
26
11
  reviewedFiles: string[];
27
12
  /** What the review was measured against, e.g. `origin/main @ 1a2b3c4` or `uncommitted work`. */
28
13
  against: string;
14
+ /** What this team learned that applies to the change (verified lessons; rules when `gates.deep.repo_rules` is on), keyed for asking once. */
15
+ guidance: TeamGuidance[];
16
+ }
17
+ export interface TeamGuidance {
18
+ key: string;
19
+ text: string;
29
20
  }
30
21
  /** `sessionBaseline`: the commit the session started from, used on main (session-state.ts). */
31
22
  export declare function stopReview(cwd: string, config: Config, attempt: number, sessionBaseline?: string): Promise<StopDecision>;
23
+ /** Asked once per new lesson or rule: the senior's question at the end of the task, never a loop. */
24
+ export declare function teamMessage(guidance: TeamGuidance[]): string;
32
25
  export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
33
26
  export declare function stopMessage(findings: Failure[], attempt: number): string;
@@ -1,8 +1,25 @@
1
+ /**
2
+ * The "before you say done" review: when an agent is about to stop, review the
3
+ * whole branch against the commit it left main at (committed, uncommitted and new
4
+ * files alike; on main itself, what the session changed since its baseline) and,
5
+ * if it introduced serious findings, tell the agent what to fix instead of
6
+ * letting it finish. A review with nothing to look at says so; it is never
7
+ * reported as a clean pass.
8
+ *
9
+ * What blocks is what the review itself reports (review/quiet.ts `mustFix`: proven, critical, or
10
+ * high and verified or from a security gate), plus on a branch a merge conflict with main or a
11
+ * mention of a file the branch deleted (review/branch-checks.ts). The same rule decides the push
12
+ * gate, so a stop, a review and a push never disagree about a finding. A high heuristic (a regex
13
+ * that sees `fetch` without `.catch`) is not enough to hold an agent back; callers also cap the
14
+ * number of attempts.
15
+ */
16
+ import { createHash } from 'crypto';
1
17
  import { reviewChange } from '../review/review.js';
2
18
  import { buildReviewTask } from '../review/review-task.js';
3
19
  import { diffFromGit } from '../review/git-diff.js';
4
20
  import { branchBase } from '../gates/logic-drift-git-base.js';
5
21
  import { branchFailures } from '../review/branch-checks.js';
22
+ import { describeLesson } from '../review-learning/team-lessons.js';
6
23
  export const STOP_MAX_ATTEMPTS = 3;
7
24
  const MAX_LISTED = 8;
8
25
  /** The branch since it left main; on main, what the session changed since its baseline (session-state.ts). */
@@ -27,8 +44,9 @@ export async function stopReview(cwd, config, attempt, sessionBaseline) {
27
44
  ? result.gateErrors.map(id => ({ id, title: 'A check could not run', details: id === 'typed-checks-unavailable' && result.typedError ? result.typedError : `${id} crashed instead of running`, severity: 'high', files: [], hint: 'Fix the environment (dependencies, generated config), then try again.' }))
28
45
  : [];
29
46
  const blocking = [...result.findings, ...whole, ...crashed];
30
- const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
31
- const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines), against };
47
+ const task = buildReviewTask(cwd, diff, config.gates.deep?.router, config.gates.deep?.review_lessons, config.gates.deep?.repo_rules ?? false);
48
+ const unreviewed = config.hooks?.require_review_ack ? task.items : [];
49
+ const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines), against, guidance: teamGuidance(task) };
32
50
  if (blocking.length === 0 && unreviewed.length === 0)
33
51
  return { block: false, message: '', blocking: 0, ...reviewed };
34
52
  const message = [
@@ -37,6 +55,22 @@ export async function stopReview(cwd, config, attempt, sessionBaseline) {
37
55
  ].join('\n\n');
38
56
  return { block: true, message, blocking: blocking.length + unreviewed.length, ...reviewed };
39
57
  }
58
+ /** Each lesson and rule once, keyed by its text so the same one is never asked twice in a session. */
59
+ function teamGuidance(task) {
60
+ const key = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
61
+ return [
62
+ ...task.lessons.map(l => ({ key: key(`lesson\u0000${l.file}\u0000${l.text}`), text: describeLesson(l) })),
63
+ ...task.rules.map(r => ({ key: key(`rule\u0000${r.source}\u0000${r.text}`), text: `${r.source}: ${r.text}` })),
64
+ ];
65
+ }
66
+ /** Asked once per new lesson or rule: the senior's question at the end of the task, never a loop. */
67
+ export function teamMessage(guidance) {
68
+ return [
69
+ 'Before you finish, check this change against what this team learned on earlier work that applies to it.',
70
+ 'If the change repeats one, fix it; if none applies, say so in one line and finish:',
71
+ ...guidance.slice(0, MAX_LISTED).map(g => `- ${g.text}`),
72
+ ].join('\n');
73
+ }
40
74
  export function reviewAckMessage(items, attempt) {
41
75
  const listed = items.slice(0, MAX_LISTED).map(i => `- ${i.file}:${i.start} \`${i.function}\`: ${i.questions[0]}`);
42
76
  return [
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/
26
26
  export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
27
27
  export { runToolchain, type ToolResult, type ToolStatus } from './review/toolchain.js';
28
28
  export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
29
- export { runReviewer, reviewerBlocks, itemLine, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
29
+ export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
30
30
  export { startBackgroundReview, backgroundReview, reviewStatus, type ReviewStatus } from './review/reviewer/background.js';
31
31
  export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
32
32
  export { resolveReviewer, saveUserReviewer, type ResolvedReviewer, type RunChoice, type UserReviewerPatch } from './review/reviewer/settings.js';
@@ -38,8 +38,10 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type Funct
38
38
  export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.js';
39
39
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
40
40
  export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
41
- export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath, type ReviewLesson } from './review-learning/lessons.js';
41
+ export { ruleWriterFor } from './review/reviewer/rule-writer.js';
42
+ export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
42
43
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
44
+ export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
43
45
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
44
46
  export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
45
47
  export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ export { buildReviewTask } from './review/review-task.js';
26
26
  export { buildQualityReceipt, receiptReport } from './review/receipt.js';
27
27
  export { runToolchain } from './review/toolchain.js';
28
28
  export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
29
- export { runReviewer, reviewerBlocks, itemLine } from './review/reviewer.js';
29
+ export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken } from './review/reviewer.js';
30
30
  export { startBackgroundReview, backgroundReview, reviewStatus } from './review/reviewer/background.js';
31
31
  export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
32
32
  export { resolveReviewer, saveUserReviewer } from './review/reviewer/settings.js';
@@ -38,8 +38,10 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './d
38
38
  export { routeFiles } from './deep/router.js';
39
39
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
40
40
  export { learnFromReviews } from './review-learning/learn-from-reviews.js';
41
- export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
41
+ export { ruleWriterFor } from './review/reviewer/rule-writer.js';
42
+ export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
42
43
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
44
+ export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
43
45
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
44
46
  export { diffFromGit, GitDiffError } from './review/git-diff.js';
45
47
  export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
@@ -8,12 +8,12 @@
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { branchBase } from '../gates/logic-drift-git-base.js';
11
+ import { bodyPoints, firstLine } from '../review-learning/review-points.js';
11
12
  import { LEDGER_PATH, LedgerSchema } from './backtest.js';
12
13
  import { defaultExec, githubEnv, parseJsonArrays } from './reviewer.js';
13
14
  const GH_TIMEOUT_MS = 60_000;
14
15
  /** Lines either side of an inline comment that a finding for the same point may land on. */
15
16
  const LINE_SLACK = 10;
16
- const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
17
17
  export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
18
18
  const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
19
19
  const gh = (args) => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
@@ -38,11 +38,11 @@ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
38
38
  for (const [i, line] of bodyPoints(String(review.body ?? '')).entries()) {
39
39
  points.push({ id: `R${round}-B${i + 1}`, point: line, file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' });
40
40
  }
41
- const base = await exec('git', ['rev-list', '-1', `--before=${review.submitted_at}`, mainRef], { cwd, timeoutMs: GH_TIMEOUT_MS });
41
+ const base = await baseAt(cwd, String(review.commit_id), review.submitted_at, mainRef, exec);
42
42
  rounds.push({
43
43
  id: `pr${pr}-r${round}`,
44
44
  commit: String(review.commit_id),
45
- base: base.stdout.trim() || mainRef,
45
+ base,
46
46
  reviewed_at: String(review.submitted_at),
47
47
  pr,
48
48
  points,
@@ -66,14 +66,6 @@ function inlinePoint(id, comment) {
66
66
  ...(line > 0 ? { lines: [Math.max(1, line - LINE_SLACK), line + LINE_SLACK] } : { needs: 'a line window or a text pattern (the comment has no line)' }),
67
67
  };
68
68
  }
69
- /** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
70
- function bodyPoints(body) {
71
- return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
72
- }
73
- function firstLine(text) {
74
- const line = text.split('\n').find(l => l.trim())?.replace(/[*_`#]/g, '').trim() ?? '';
75
- return line.length > 140 ? `${line.slice(0, 137)}...` : line;
76
- }
77
69
  function escape(text) {
78
70
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
71
  }
@@ -85,3 +77,13 @@ function existingLedger(file) {
85
77
  throw new Error(`${LEDGER_PATH} exists but is not a ledger; fix or remove it first`);
86
78
  return parsed.data;
87
79
  }
80
+ /**
81
+ * What the reviewer saw the change against: the merge-base of the reviewed commit and main as of the review.
82
+ * Not main itself: a branch that merged main in already has main's newer commits, and diffing from an older
83
+ * main would review them as the branch's own. Main as of the review when the commit is not fetched here.
84
+ */
85
+ async function baseAt(cwd, commit, reviewedAt, mainRef, exec) {
86
+ const main = (await exec('git', ['rev-list', '-1', `--before=${reviewedAt}`, mainRef], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim() || mainRef;
87
+ const mergeBase = await exec('git', ['merge-base', commit, main], { cwd, timeoutMs: GH_TIMEOUT_MS });
88
+ return (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) || main;
89
+ }
@@ -20,8 +20,14 @@ const INLINE = [
20
20
  function fakeExec(calls) {
21
21
  return async (command, args, options) => {
22
22
  calls.push([command, ...args]);
23
- if (command === 'git')
24
- return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8' }), stderr: '' };
23
+ if (command === 'git') {
24
+ try {
25
+ return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }), stderr: '' };
26
+ }
27
+ catch (error) {
28
+ return { exitCode: error.status ?? 1, stdout: '', stderr: String(error.stderr ?? '') };
29
+ }
30
+ }
25
31
  if (args[0] === 'auth')
26
32
  return { exitCode: 0, stdout: 'token\n', stderr: '' };
27
33
  if (args[0] === 'pr')
@@ -43,6 +49,24 @@ beforeEach(() => {
43
49
  });
44
50
  afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
45
51
  describe('rigour backtest init', () => {
52
+ it('takes the base from where the reviewed commit left main, not from main as of the review', async () => {
53
+ const forked = git('rev-parse', 'HEAD');
54
+ git('checkout', '-q', '-b', 'feature');
55
+ fs.writeFileSync(path.join(repo, 'b.ts'), 'export const b = 1;\n');
56
+ git('add', '-A');
57
+ execFileSync('git', ['-C', repo, 'commit', '-qm', 'the branch'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-04-12T09:30:00Z', GIT_AUTHOR_DATE: '2026-04-12T09:30:00Z' } });
58
+ const reviewed = git('rev-parse', 'HEAD');
59
+ git('checkout', '-q', 'main');
60
+ fs.writeFileSync(path.join(repo, 'c.ts'), 'export const c = 1;\n');
61
+ git('add', '-A');
62
+ execFileSync('git', ['-C', repo, 'commit', '-qm', 'main moves on before the review'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-04-12T11:00:00Z', GIT_AUTHOR_DATE: '2026-04-12T11:00:00Z' } });
63
+ const realCommit = (calls) => async (command, args, options) => {
64
+ const answer = await fakeExec(calls)(command, args, options);
65
+ return command === 'gh' && args[1]?.endsWith('/reviews') ? { ...answer, stdout: JSON.stringify(REVIEWS.map(r => ({ ...r, commit_id: reviewed }))) } : answer;
66
+ };
67
+ const { rounds } = await scaffoldLedger(repo, 212, config, realCommit([]));
68
+ expect(rounds[0].base).toBe(forked);
69
+ });
46
70
  it('writes one round per human review: inline comments become file-and-window points, body bullets need a pattern', async () => {
47
71
  const calls = [];
48
72
  const mainAtReview = git('rev-parse', 'HEAD');
@@ -13,6 +13,7 @@
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
15
  import { z } from 'zod';
16
+ import { branchBase } from '../gates/logic-drift-git-base.js';
16
17
  import { reviewChange } from './review.js';
17
18
  import { defaultExec, runReviewer } from './reviewer.js';
18
19
  import { reviewerInputs } from './reviewer/context.js';
@@ -91,6 +92,9 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
91
92
  const worktree = await worktreeFor(cwd, round.commit, exec);
92
93
  const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
93
94
  progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
95
+ const stale = await staleBase(worktree, head, round.base, exec);
96
+ if (stale)
97
+ progress(`backtest ${round.id}: warning: ${stale}`);
94
98
  const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
95
99
  const { items, reviewerError, judged } = await collect(worktree, round, config);
96
100
  const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}) };
@@ -99,6 +103,25 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
99
103
  }
100
104
  return results;
101
105
  }
106
+ /**
107
+ * Why a round's base would review main's own commits as the branch's: the head already contains a newer
108
+ * main than the base (the branch merged main in), so the diff from the base carries everything main did since.
109
+ */
110
+ async function staleBase(worktree, head, base, exec) {
111
+ const mainRef = branchBase(worktree)?.mainRef;
112
+ if (!mainRef)
113
+ return undefined;
114
+ const git = async (...args) => (await exec('git', args, { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
115
+ const [mergeBase, baseSha] = await Promise.all([git('merge-base', head, mainRef), git('rev-parse', `${base}^{commit}`)]);
116
+ // A merged head is on main already: its merge-base with main is itself, and says nothing about the base.
117
+ if (!mergeBase || !baseSha || mergeBase === baseSha || mergeBase === head)
118
+ return undefined;
119
+ // Only a base strictly older than that main is stale; a newer one means the local main ref is the stale thing.
120
+ const older = await exec('git', ['merge-base', '--is-ancestor', baseSha, mergeBase], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS });
121
+ if (older.exitCode !== 0)
122
+ return undefined;
123
+ return `the head already contains ${mainRef} up to ${mergeBase.slice(0, 9)}, so diffing from ${base} reviews main's own commits as the branch's; set the base to ${mergeBase.slice(0, 9)}`;
124
+ }
102
125
  /** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
103
126
  export function backtestPassed(results) {
104
127
  return results.every(r => r.points.every(p => p.caught) && r.falseBlocks.length === 0 && !r.reviewerError);
@@ -131,13 +154,22 @@ async function worktreeFor(cwd, commit, exec) {
131
154
  const reason = resolved.stderr.trim().split('\n').at(-1);
132
155
  throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)${reason ? `: git said "${reason}"` : ''}`);
133
156
  }
134
- const dir = path.join(common, 'rigour-backtest', resolved.stdout.trim().slice(0, 12));
157
+ // One worktree, moved from round to round: a checkout per commit filled the disk on a backtest of dozens of pull requests.
158
+ const dir = path.join(common, 'rigour-backtest', 'checkout');
159
+ const sha = resolved.stdout.trim();
135
160
  if (!fs.existsSync(path.join(dir, '.git'))) {
136
161
  fs.mkdirSync(path.dirname(dir), { recursive: true });
137
- const added = await exec('git', ['worktree', 'add', '--detach', dir, resolved.stdout.trim()], { cwd, timeoutMs: 5 * GIT_TIMEOUT_MS });
162
+ const added = await exec('git', ['worktree', 'add', '--detach', dir, sha], { cwd, timeoutMs: 5 * GIT_TIMEOUT_MS });
138
163
  if (added.exitCode !== 0)
139
164
  throw new Error(`could not check out ${commit} for the backtest: ${added.stderr.trim()}`);
140
165
  }
166
+ else {
167
+ const moved = await exec('git', ['checkout', '-q', '--detach', '--force', sha], { cwd: dir, timeoutMs: 5 * GIT_TIMEOUT_MS });
168
+ if (moved.exitCode !== 0)
169
+ throw new Error(`could not check out ${commit} for the backtest: ${moved.stderr.trim()}`);
170
+ // What the last round left behind (untracked files) must not reach this one; the linked dependencies stay.
171
+ await exec('git', ['clean', '-q', '-fdx', '-e', 'node_modules'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
172
+ }
141
173
  shareDependencies(cwd, dir);
142
174
  return dir;
143
175
  }
@@ -160,7 +192,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
160
192
  ];
161
193
  if (!reviewer)
162
194
  return { items };
163
- const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true, ...reviewerInputs(review) });
195
+ const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
164
196
  if (result.outcome === 'unavailable' || result.outcome === 'skipped')
165
197
  return { items, reviewerError: result.reason ?? result.outcome };
166
198
  // The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
@@ -104,7 +104,7 @@ describe('rigour backtest on a repository', () => {
104
104
  ],
105
105
  must_not_flag: [{ file: 'app\\.ts', lines: [1, 1] }],
106
106
  }] }));
107
- const config = ConfigSchema.parse({ version: 1 });
107
+ const config = ConfigSchema.parse({ version: 1, gates: { unused_exports: { block: true } } });
108
108
  const results = await runBacktest(repo, config, loadLedger(repo), { progress: () => undefined });
109
109
  expect(results).toHaveLength(1);
110
110
  expect(results[0].points).toMatchObject([{ id: 'R1-1', caught: true, by: 'unused-export src/lib/util.ts:1' }, { id: 'R1-2', caught: false, noted: false }]);
@@ -113,12 +113,54 @@ describe('rigour backtest on a repository', () => {
113
113
  expect(backtestPassed(results)).toBe(false);
114
114
  expect(git('branch', '--show-current')).toBe('main');
115
115
  expect(fs.existsSync(path.join(repo, 'src/lib/util.ts'))).toBe(false); // the checkout did not move
116
- expect(fs.existsSync(path.join(repo, '.git/rigour-backtest', reviewed.slice(0, 12), 'src/lib/util.ts'))).toBe(true);
116
+ expect(fs.existsSync(path.join(repo, '.git/rigour-backtest/checkout/src/lib/util.ts'))).toBe(true);
117
117
  expect(fs.readdirSync(path.join(repo, '.rigour/backtest'))).toEqual([`r1-${reviewed.slice(0, 12)}.json`]);
118
118
  // The worktree is reused on the next run.
119
119
  const again = await runBacktest(repo, config, loadLedger(repo), { round: 'r1', progress: () => undefined });
120
120
  expect(again[0].points[0].caught).toBe(true);
121
121
  }, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
122
+ it('warns when the base is older than the main the head already merged in', async () => {
123
+ const forked = git('rev-parse', 'main');
124
+ git('checkout', '-q', 'main');
125
+ write('src/main-only.ts', 'export const later = 1;\n');
126
+ git('add', '-A');
127
+ git('commit', '-qm', 'main moves on');
128
+ const merged = git('rev-parse', 'HEAD');
129
+ git('checkout', '-q', 'feature');
130
+ git('merge', '-q', '--no-edit', 'main');
131
+ const reviewed = git('rev-parse', 'HEAD');
132
+ const config = ConfigSchema.parse({ version: 1 });
133
+ const run = async (base) => {
134
+ const lines = [];
135
+ const ledger = { rounds: [{ id: 'r1', commit: reviewed, base, points: [], must_not_flag: [] }] };
136
+ await runBacktest(repo, config, ledger, { progress: line => lines.push(line), collect: async () => ({ items: [] }) });
137
+ return lines.filter(line => line.includes('warning'));
138
+ };
139
+ expect(await run(forked)).toEqual([expect.stringContaining(`set the base to ${merged.slice(0, 9)}`)]);
140
+ expect(await run(merged)).toEqual([]);
141
+ git('update-ref', 'refs/heads/main', forked); // a local main older than the round's base: the ref is stale, not the base
142
+ expect(await run(merged)).toEqual([]);
143
+ git('update-ref', 'refs/heads/main', merged);
144
+ git('checkout', '-q', 'main');
145
+ git('merge', '-q', '--no-edit', 'feature'); // the pull request is merged: its head is on main now
146
+ expect(await run(merged)).toEqual([]);
147
+ }, 60_000);
148
+ it('moves one worktree from round to round, leaving nothing of the last round behind', async () => {
149
+ const reviewed = git('rev-parse', 'HEAD');
150
+ const base = git('rev-parse', 'main');
151
+ const seen = [];
152
+ const ledger = { rounds: [
153
+ { id: 'after', commit: reviewed, base, points: [], must_not_flag: [] },
154
+ { id: 'before', commit: base, base, points: [], must_not_flag: [] },
155
+ ] };
156
+ await runBacktest(repo, ConfigSchema.parse({ version: 1 }), ledger, { progress: () => undefined, collect: async (tree) => {
157
+ seen.push({ tree, util: fs.existsSync(path.join(tree, 'src/lib/util.ts')), leftover: fs.existsSync(path.join(tree, 'scratch.txt')) });
158
+ fs.writeFileSync(path.join(tree, 'scratch.txt'), 'left by a round');
159
+ return { items: [] };
160
+ } });
161
+ expect(seen.map(s => s.tree)).toEqual([seen[0].tree, seen[0].tree]);
162
+ expect(seen.map(s => [s.util, s.leftover])).toEqual([[true, false], [false, false]]);
163
+ }, 60_000);
122
164
  it('names a round or a commit it cannot find', async () => {
123
165
  const config = ConfigSchema.parse({ version: 1 });
124
166
  const ledger = { rounds: [{ id: 'r1', commit: '0000000000000000000000000000000000000000', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] }] };
@@ -5,6 +5,7 @@ import path from 'path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { ConfigSchema } from '../types/index.js';
7
7
  import { diffFromGit } from './git-diff.js';
8
+ import { mustFix } from './quiet.js';
8
9
  import { orphanFileFailures } from './orphan-files.js';
9
10
  import { unusedExportFailures } from './unused-exports.js';
10
11
  let repo;
@@ -87,6 +88,18 @@ describe('unused exports', () => {
87
88
  write('rigour-report.json', '{"failures":[{"details":"orphanName"}]}\n');
88
89
  expect(unusedExportFailures(repo, diffFromGit(repo), config())).toHaveLength(1);
89
90
  });
91
+ it('reports dead code as a note unless the team blocks on it', () => {
92
+ write('src/app.ts', 'export function start() {}\nexport function lonely() { return 1; }\n');
93
+ write('src/new-tool.ts', 'export const tool = 1;\n');
94
+ const found = (gates = {}) => [
95
+ ...unusedExportFailures(repo, diffFromGit(repo), config(gates)),
96
+ ...orphanFileFailures(repo, diffFromGit(repo), config(gates)),
97
+ ];
98
+ expect(found().map(f => [f.id, mustFix(f)])).toEqual(expect.arrayContaining([['unused-export', false], ['orphan-file', false]]));
99
+ const strict = found({ unused_exports: { block: true }, orphan_files: { block: true } });
100
+ expect(strict.length).toBeGreaterThan(0);
101
+ expect(strict.every(mustFix)).toBe(true);
102
+ });
90
103
  it('is off when disabled', () => {
91
104
  write('src/app.ts', 'export function start() {}\nexport const x = 1;\n');
92
105
  expect(unusedExportFailures(repo, diffFromGit(repo), config({ unused_exports: { enabled: false } }))).toEqual([]);
@@ -41,7 +41,7 @@ export function orphanFileFailures(cwd, diff, config) {
41
41
  if (!files)
42
42
  return []; // git could not answer: say nothing rather than guess
43
43
  const referrers = referrersOf(cwd, added, files);
44
- return [...unreferenced(added, referrers)].map(orphan);
44
+ return [...unreferenced(added, referrers)].map(file => orphan(file, !settings.block));
45
45
  }
46
46
  /** Tracked and new (untracked, not ignored) files. */
47
47
  function repositoryFiles(cwd) {
@@ -116,9 +116,10 @@ function unreferenced(added, referrers) {
116
116
  }
117
117
  return suspects;
118
118
  }
119
- function orphan(file) {
119
+ function orphan(file, advisory) {
120
120
  return {
121
121
  id: 'orphan-file',
122
+ ...(advisory ? { advisory } : {}),
122
123
  title: 'Orphaned file',
123
124
  details: `\`${file}\` is new, but nothing outside the change's new files imports or runs it.`,
124
125
  severity: 'medium',
@@ -3,7 +3,7 @@ export declare const DISMISSED_FILE: string;
3
3
  export declare function isProven(failure: Failure): boolean;
4
4
  /**
5
5
  * What a change must fix: a proven finding, a critical one, or a high one the semantic engine
6
- * verified or a security gate found. The one rule behind the review's verdict, the stop hook and
6
+ * verified or a security gate found. Never one the team keeps as a note (`advisory`, from a check's `block: false`). The one rule behind the review's verdict, the stop hook and
7
7
  * the push gate, so the three never disagree about the same finding.
8
8
  */
9
9
  export declare function mustFix(failure: Failure): boolean;
@@ -28,10 +28,12 @@ export function isProven(failure) {
28
28
  }
29
29
  /**
30
30
  * What a change must fix: a proven finding, a critical one, or a high one the semantic engine
31
- * verified or a security gate found. The one rule behind the review's verdict, the stop hook and
31
+ * verified or a security gate found. Never one the team keeps as a note (`advisory`, from a check's `block: false`). The one rule behind the review's verdict, the stop hook and
32
32
  * the push gate, so the three never disagree about the same finding.
33
33
  */
34
34
  export function mustFix(failure) {
35
+ if (failure.advisory)
36
+ return false;
35
37
  const severity = failure.severity ?? 'medium';
36
38
  return isProven(failure) || severity === 'critical' || (severity === 'high' && (failure.verified === true || failure.provenance === 'security'));
37
39
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { type RouterPolicy } from '../deep/router.js';
12
12
  import { type ReviewedKey } from './ledger.js';
13
- import { type LessonMode } from '../review-learning/team-lessons.js';
13
+ import { type LessonMode, type LessonView } from '../review-learning/team-lessons.js';
14
14
  export interface ReviewTaskItem {
15
15
  file: string;
16
16
  function: string;
@@ -26,11 +26,7 @@ export interface ReviewTask {
26
26
  /** Risky functions already reviewed at their current content. */
27
27
  alreadyReviewed: number;
28
28
  /** The team's past review lessons that apply to this change. */
29
- lessons: Array<{
30
- file: string;
31
- text: string;
32
- prs: number[];
33
- }>;
29
+ lessons: LessonView[];
34
30
  /** The repository's own rules (AGENTS.md, …) that name what this change touches. */
35
31
  rules: Array<{
36
32
  source: string;
@@ -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 { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff } from '../review-learning/team-lessons.js';
15
+ import { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff, lessonView } 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?',
@@ -28,7 +28,7 @@ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = DEFAULT_LES
28
28
  const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff), activeLessons(cwd, lessonMode));
29
29
  const risky = ranked.filter(f => f.score >= (policy.min_score ?? DEFAULT_MIN_SCORE)).slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
30
30
  const pending = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
31
- const lessons = lessonsForDiff(cwd, diff, lessonMode).map(l => ({ file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))] }));
31
+ const lessons = lessonsForDiff(cwd, diff, lessonMode).map(lessonView);
32
32
  return {
33
33
  items: pending.map(toItem),
34
34
  alreadyReviewed: risky.length - pending.length,
@@ -16,6 +16,27 @@ export interface Adapter {
16
16
  export interface Spend {
17
17
  costUsd?: number;
18
18
  tokens?: Tokens;
19
+ trace?: RunTrace;
20
+ }
21
+ /**
22
+ * Where a run's tokens went, turn by turn, and what each tool call read: measurement only, never a
23
+ * decision. `category` is filled in by the reviewer, which knows its own input files and the change.
24
+ */
25
+ export interface RunTrace {
26
+ turns: number;
27
+ usage: {
28
+ input: number;
29
+ cacheRead: number;
30
+ cacheWrite: number;
31
+ output: number;
32
+ };
33
+ calls: Array<{
34
+ turn: number;
35
+ tool: string;
36
+ target: string;
37
+ resultChars: number;
38
+ category?: 'rigour-input' | 'changed-file' | 'other-file' | 'git' | 'search' | 'other';
39
+ }>;
19
40
  }
20
41
  export interface Tokens {
21
42
  input: number;