@rigour-labs/core 6.6.2 → 6.6.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 (83) hide show
  1. package/dist/deep/diff-tests/run.js +2 -12
  2. package/dist/firewall/firewall.test.js +4 -0
  3. package/dist/firewall/trusted-control.js +2 -2
  4. package/dist/firewall/typed-command.js +2 -1
  5. package/dist/gates/logic-drift-git-base.d.ts +9 -0
  6. package/dist/gates/logic-drift-git-base.js +16 -11
  7. package/dist/gates/runner.d.ts +3 -0
  8. package/dist/gates/runner.js +4 -1
  9. package/dist/hooks/index.d.ts +2 -2
  10. package/dist/hooks/index.js +2 -2
  11. package/dist/hooks/session-state.d.ts +9 -0
  12. package/dist/hooks/session-state.js +37 -0
  13. package/dist/hooks/stop-review.d.ts +18 -11
  14. package/dist/hooks/stop-review.js +24 -8
  15. package/dist/hooks/templates.d.ts +8 -1
  16. package/dist/hooks/templates.js +19 -2
  17. package/dist/hooks/templates.test.js +12 -1
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.js +4 -0
  20. package/dist/review/baseline.d.ts +16 -0
  21. package/dist/review/baseline.js +96 -0
  22. package/dist/review/baseline.test.d.ts +1 -0
  23. package/dist/review/baseline.test.js +22 -0
  24. package/dist/review/branch-checks.d.ts +6 -0
  25. package/dist/review/branch-checks.js +83 -0
  26. package/dist/review/branch-checks.test.d.ts +1 -0
  27. package/dist/review/branch-checks.test.js +48 -0
  28. package/dist/review/code-patterns.test.d.ts +1 -0
  29. package/dist/review/code-patterns.test.js +84 -0
  30. package/dist/review/dead-code.test.d.ts +1 -0
  31. package/dist/review/dead-code.test.js +126 -0
  32. package/dist/review/duplicate-functions.d.ts +2 -0
  33. package/dist/review/duplicate-functions.js +98 -0
  34. package/dist/review/effectiveness.d.ts +3 -0
  35. package/dist/review/optional-params.d.ts +2 -0
  36. package/dist/review/optional-params.js +123 -0
  37. package/dist/review/orphan-files.d.ts +2 -0
  38. package/dist/review/orphan-files.js +125 -0
  39. package/dist/review/package-layout.d.ts +9 -0
  40. package/dist/review/package-layout.js +108 -0
  41. package/dist/review/query-patterns.d.ts +2 -0
  42. package/dist/review/query-patterns.js +153 -0
  43. package/dist/review/quiet.js +1 -1
  44. package/dist/review/review.d.ts +2 -0
  45. package/dist/review/review.js +29 -1
  46. package/dist/review/review.test.js +28 -2
  47. package/dist/review/reviewer.d.ts +55 -0
  48. package/dist/review/reviewer.js +224 -0
  49. package/dist/review/reviewer.test.d.ts +1 -0
  50. package/dist/review/reviewer.test.js +99 -0
  51. package/dist/review/test-files.d.ts +2 -0
  52. package/dist/review/test-files.js +4 -0
  53. package/dist/review/toolchain.d.ts +11 -0
  54. package/dist/review/toolchain.js +126 -0
  55. package/dist/review/toolchain.test.d.ts +1 -0
  56. package/dist/review/toolchain.test.js +67 -0
  57. package/dist/review/unused-exports.d.ts +4 -0
  58. package/dist/review/unused-exports.js +189 -0
  59. package/dist/settings.js +2 -3
  60. package/dist/storage/db.js +1 -2
  61. package/dist/storage/team-scope.d.ts +20 -0
  62. package/dist/storage/team-scope.js +32 -0
  63. package/dist/storage/team-scope.test.d.ts +1 -0
  64. package/dist/storage/team-scope.test.js +22 -0
  65. package/dist/storage/team-store.d.ts +5 -0
  66. package/dist/storage/team-store.js +42 -7
  67. package/dist/storage/team-sync-scope.test.d.ts +1 -0
  68. package/dist/storage/team-sync-scope.test.js +67 -0
  69. package/dist/telemetry/telemetry.js +5 -5
  70. package/dist/templates/universal-config.js +23 -0
  71. package/dist/types/index.d.ts +253 -6
  72. package/dist/types/index.js +27 -0
  73. package/dist/utils/installed-bin.d.ts +2 -0
  74. package/dist/utils/installed-bin.js +15 -0
  75. package/dist/utils/profile.d.ts +30 -0
  76. package/dist/utils/profile.js +116 -0
  77. package/dist/utils/profile.test.d.ts +1 -0
  78. package/dist/utils/profile.test.js +58 -0
  79. package/dist/utils/scanner.js +3 -1
  80. package/dist/utils/scanner.test.js +6 -2
  81. package/dist/utils/user-state.d.ts +3 -1
  82. package/dist/utils/user-state.js +6 -2
  83. package/package.json +10 -6
@@ -20,6 +20,7 @@ import { loadProjectConfig, programBatches } from '../../semantic/program.js';
20
20
  import { ProjectFacts } from '../../semantic/project-facts.js';
21
21
  import { changedFunctions, functionName, isExported } from '../changed-functions.js';
22
22
  import { safeCalls, testFileSource } from './calls.js';
23
+ import { installedBin } from '../../utils/installed-bin.js';
23
24
  const MAX_TARGETS = 5;
24
25
  /** A junction on Windows needs no admin rights; elsewhere a plain directory symlink. */
25
26
  const DIR_LINK = process.platform === 'win32' ? 'junction' : 'dir';
@@ -115,7 +116,7 @@ async function record(root, head, target, calls, timeoutMs = 120_000) {
115
116
  const packageDir = path.join(root, path.relative(head, target.packageDir));
116
117
  fs.writeFileSync(testFile, testFileSource(target.runner, target.name, importPath, calls));
117
118
  try {
118
- const runner = installedRunner(packageDir, root, target.runner);
119
+ const runner = installedBin(packageDir, root, target.runner);
119
120
  const command = runner && `${runner} ${target.runner === 'vitest' ? 'run ' : ''}${path.relative(packageDir, testFile)}`;
120
121
  if (!command || evaluateTypedCommand(command).decision !== 'allow')
121
122
  return {};
@@ -128,17 +129,6 @@ async function record(root, head, target, calls, timeoutMs = 120_000) {
128
129
  fs.rmSync(outFile, { force: true });
129
130
  }
130
131
  }
131
- /** The package's own installed runner, never a download: node_modules/.bin up to the repository root. */
132
- function installedRunner(packageDir, root, runner) {
133
- for (let dir = packageDir; dir.startsWith(root); dir = path.dirname(dir)) {
134
- const bin = path.join(dir, 'node_modules', '.bin', runner);
135
- if (fs.existsSync(bin))
136
- return bin;
137
- if (dir === root)
138
- break;
139
- }
140
- return undefined;
141
- }
142
132
  /** The base worktree has no installed packages: link the head's node_modules into it. */
143
133
  function linkDependencies(head, base) {
144
134
  const walk = (dir, depth) => {
@@ -6,6 +6,10 @@ describe('typed command firewall', () => {
6
6
  expect(ev.decision).toBe('deny');
7
7
  expect(ev.ruleId).toBe('shell.no-meta');
8
8
  });
9
+ it('treats a Windows shim as the program it starts, and still denies an unknown one', () => {
10
+ expect(evaluateTypedCommand('D:\\repo\\node_modules\\.bin\\vitest.cmd run a.test.ts').decision).toBe('allow');
11
+ expect(evaluateTypedCommand('D:\\tools\\curl.exe https://evil.example').decision).toBe('deny');
12
+ });
9
13
  it('denies unknown binaries including node/npx', () => {
10
14
  expect(evaluateTypedCommand('curl https://evil.example').decision).toBe('deny');
11
15
  expect(evaluateTypedCommand('node -e "1"').decision).toBe('deny');
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
2
  import fs from 'fs-extra';
3
- import os from 'os';
4
3
  import path from 'path';
4
+ import { rigourUserDir } from '../utils/user-state.js';
5
5
  import { hashPolicy } from './policy-hash.js';
6
6
  function canonicalRepository(cwd) {
7
7
  try {
@@ -14,7 +14,7 @@ function canonicalRepository(cwd) {
14
14
  export function getRepositoryControlId(cwd) {
15
15
  return createHash('sha256').update(canonicalRepository(cwd)).digest('hex').slice(0, 24);
16
16
  }
17
- export function getTrustedControlDir(cwd, controlRoot = path.join(os.homedir(), '.rigour', 'control')) {
17
+ export function getTrustedControlDir(cwd, controlRoot = path.join(rigourUserDir(), 'control')) {
18
18
  return path.join(controlRoot, getRepositoryControlId(cwd));
19
19
  }
20
20
  export function getGatewayPolicyHash(cwd, config) {
@@ -72,7 +72,8 @@ export function evaluateTypedCommand(command, allowedBins = DEFAULT_ALLOWED_BINS
72
72
  timestamp: new Date().toISOString(),
73
73
  };
74
74
  }
75
- const binBase = parsed.bin.split(/[/\\]/).pop() || parsed.bin;
75
+ // A Windows shim or executable (vitest.cmd, git.exe) is the program it starts.
76
+ const binBase = (parsed.bin.split(/[/\\]/).pop() || parsed.bin).replace(/\.(cmd|exe|bat)$/i, '');
76
77
  if (!allowedBins.has(binBase) && !allowedBins.has(parsed.bin)) {
77
78
  return {
78
79
  decision: 'deny',
@@ -3,5 +3,14 @@ export interface GitLogicBase {
3
3
  readAtBase: (file: string) => string | null;
4
4
  }
5
5
  export declare function isGitWorktree(cwd: string): boolean;
6
+ export interface BranchBase {
7
+ /** The main branch as found: GITHUB_BASE_REF, else origin/main, main, origin/master, master. */
8
+ mainRef: string;
9
+ /** Where the branch left it (merge-base), or HEAD when on main itself. */
10
+ base: string;
11
+ onMain: boolean;
12
+ }
13
+ /** The commit a branch is measured against, or null outside a repository or without a main branch. */
14
+ export declare function branchBase(cwd: string): BranchBase | null;
6
15
  /** Use a fixed commit for the entire scan; never move the baseline on read. */
7
16
  export declare function resolveGitLogicBase(cwd: string): GitLogicBase | null;
@@ -10,13 +10,8 @@ function git(cwd, args) {
10
10
  return null;
11
11
  }
12
12
  }
13
- /** Use a fixed commit for the entire scan; never move the baseline on read. */
14
- export function resolveGitLogicBase(cwd) {
15
- // Git reports the path relative to the worktree root without relying on
16
- // platform-specific path casing or Windows short-name expansion.
17
- const prefix = git(cwd, ['rev-parse', '--show-prefix']);
18
- if (prefix === null || prefix.trim())
19
- return null;
13
+ /** The commit a branch is measured against, or null outside a repository or without a main branch. */
14
+ export function branchBase(cwd) {
20
15
  const currentBranch = git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD'])?.trim();
21
16
  const candidates = [
22
17
  process.env.GITHUB_BASE_REF ? `refs/remotes/origin/${process.env.GITHUB_BASE_REF}` : '',
@@ -26,11 +21,21 @@ export function resolveGitLogicBase(cwd) {
26
21
  const mainRef = candidates.find(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref])?.trim());
27
22
  if (!mainRef)
28
23
  return null;
29
- const base = currentBranch === 'main' || currentBranch === 'master'
30
- ? git(cwd, ['rev-parse', 'HEAD'])?.trim()
31
- : git(cwd, ['merge-base', 'HEAD', mainRef])?.trim();
32
- if (!base)
24
+ const onMain = currentBranch === 'main' || currentBranch === 'master';
25
+ const base = onMain ? git(cwd, ['rev-parse', 'HEAD'])?.trim() : git(cwd, ['merge-base', 'HEAD', mainRef])?.trim();
26
+ return base ? { mainRef, base, onMain } : null;
27
+ }
28
+ /** Use a fixed commit for the entire scan; never move the baseline on read. */
29
+ export function resolveGitLogicBase(cwd) {
30
+ // Git reports the path relative to the worktree root without relying on
31
+ // platform-specific path casing or Windows short-name expansion.
32
+ const prefix = git(cwd, ['rev-parse', '--show-prefix']);
33
+ if (prefix === null || prefix.trim())
34
+ return null;
35
+ const branch = branchBase(cwd);
36
+ if (!branch)
33
37
  return null;
38
+ const base = branch.base;
34
39
  const diff = git(cwd, ['diff', '--name-only', '-z', '--diff-filter=ACMR', base, '--']);
35
40
  if (diff === null)
36
41
  return null;
@@ -9,8 +9,11 @@ export declare class GateRunner {
9
9
  * Allows adding custom gates dynamically (SOLID - Open/Closed Principle)
10
10
  */
11
11
  addGate(gate: Gate): void;
12
+ /** `record: false` leaves no trace (no local memory, no adaptive history): a comparison run, not a review. */
12
13
  run(cwd: string, patterns?: string[], deepOptions?: DeepOptions & {
13
14
  onProgress?: (msg: string) => void;
15
+ }, runOptions?: {
16
+ record?: boolean;
14
17
  }): Promise<Report>;
15
18
  }
16
19
  /**
@@ -138,7 +138,8 @@ export class GateRunner {
138
138
  addGate(gate) {
139
139
  this.gates.push(gate);
140
140
  }
141
- async run(cwd, patterns, deepOptions) {
141
+ /** `record: false` leaves no trace (no local memory, no adaptive history): a comparison run, not a review. */
142
+ async run(cwd, patterns, deepOptions, runOptions = {}) {
142
143
  const start = Date.now();
143
144
  const failures = [];
144
145
  const summary = {};
@@ -309,6 +310,8 @@ export class GateRunner {
309
310
  ...(deepStats ? { deep: deepStats } : {}),
310
311
  },
311
312
  };
313
+ if (runOptions.record === false)
314
+ return report;
312
315
  // Store findings + reinforce patterns in local SQLite before returning.
313
316
  // CLI commands call process.exit(), so fire-and-forget writes can be dropped.
314
317
  try {
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export { runHookChecker } from './checker.js';
6
6
  export type { CheckerOptions } from './checker.js';
7
- export { generateHookFiles } from './templates.js';
7
+ export { generateHookFiles, pushGateShell } from './templates.js';
8
8
  export type { GeneratedHookFile } from './templates.js';
9
9
  export type { HookTool, HookConfig, HookCheckerResult } from './types.js';
10
10
  export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
@@ -16,4 +16,4 @@ export { generateDLPHookFiles } from './dlp-templates.js';
16
16
  export type { GeneratedDLPHookFile } from './dlp-templates.js';
17
17
  export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
18
18
  export type { StopDecision } from './stop-review.js';
19
- export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
19
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
@@ -3,7 +3,7 @@
3
3
  *
4
4
  */
5
5
  export { runHookChecker } from './checker.js';
6
- export { generateHookFiles } from './templates.js';
6
+ export { generateHookFiles, pushGateShell } from './templates.js';
7
7
  export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
8
8
  // DLP (Data Loss Prevention) — v4.2.0
9
9
  export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './input-validator.js';
@@ -11,4 +11,4 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
11
11
  export { generateDLPHookFiles } from './dlp-templates.js';
12
12
  // Stop review ("before you say done")
13
13
  export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
14
- export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
14
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
@@ -2,6 +2,15 @@
2
2
  export declare function recordSessionBaseline(cwd: string, session: string): void;
3
3
  /** The commit the session started from, if it is still in history (a rebase can drop it). */
4
4
  export declare function sessionBaseline(cwd: string, session: string): string | undefined;
5
+ /**
6
+ * The state of the work: HEAD, the uncommitted diff, and the new files with their size and time.
7
+ * A stop in the same state as the last review (a turn that only read, or only talked) has
8
+ * nothing new to review, so the agent is not told the same thing again.
9
+ */
10
+ export declare function workFingerprint(cwd: string, ownOutputs?: string[]): string | undefined;
11
+ /** Whether the work is in the state the last stop review saw. */
12
+ export declare function alreadyReviewed(cwd: string, session: string, fingerprint: string | undefined): boolean;
13
+ export declare function recordReviewed(cwd: string, session: string, fingerprint: string | undefined): void;
5
14
  /** This stop's attempt number: blocks so far plus one. */
6
15
  export declare function nextStopAttempt(cwd: string, session: string): number;
7
16
  /** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
@@ -6,6 +6,7 @@
6
6
  * - attempts: how many times the stop review has blocked this session.
7
7
  */
8
8
  import { spawnSync } from 'child_process';
9
+ import { createHash } from 'crypto';
9
10
  import fs from 'fs';
10
11
  import path from 'path';
11
12
  import { repoStateDir } from '../utils/user-state.js';
@@ -50,6 +51,42 @@ export function sessionBaseline(cwd, session) {
50
51
  const known = spawnSync('git', ['cat-file', '-e', `${baseline}^{commit}`], { cwd });
51
52
  return known.status === 0 ? baseline : undefined;
52
53
  }
54
+ /**
55
+ * The state of the work: HEAD, the uncommitted diff, and the new files with their size and time.
56
+ * A stop in the same state as the last review (a turn that only read, or only talked) has
57
+ * nothing new to review, so the agent is not told the same thing again.
58
+ */
59
+ export function workFingerprint(cwd, ownOutputs = DEFAULT_OWN_OUTPUTS) {
60
+ const run = (args) => spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
61
+ // Rigour's own files change on every review; where a repository does not ignore them, they must not count as work.
62
+ const pathspec = ['--', '.', ...ownOutputs.map(own => `:(exclude)${own}`)];
63
+ const head = run(['rev-parse', '--verify', '--quiet', 'HEAD']);
64
+ const diff = run(['diff', 'HEAD', '--no-color', '--no-ext-diff', ...pathspec]);
65
+ const untracked = run(['ls-files', '--others', '--exclude-standard', '-z', ...pathspec]);
66
+ if (diff.status !== 0 || untracked.status !== 0)
67
+ return undefined;
68
+ const hash = createHash('sha256').update(head.stdout).update(diff.stdout);
69
+ for (const file of untracked.stdout.split('\0').filter(Boolean)) {
70
+ try {
71
+ const stat = fs.statSync(path.join(cwd, file));
72
+ hash.update(`${file}\0${stat.size}\0${stat.mtimeMs}\0`);
73
+ }
74
+ catch {
75
+ hash.update(`${file}\0gone\0`);
76
+ }
77
+ }
78
+ return hash.digest('hex');
79
+ }
80
+ /** What a review writes into the repository: its state folder and its reports. */
81
+ const DEFAULT_OWN_OUTPUTS = ['.rigour', 'rigour-report.json', 'rigour-fix-packet.json'];
82
+ /** Whether the work is in the state the last stop review saw. */
83
+ export function alreadyReviewed(cwd, session, fingerprint) {
84
+ return !!fingerprint && readStore(cwd)[session]?.reviewed === fingerprint;
85
+ }
86
+ export function recordReviewed(cwd, session, fingerprint) {
87
+ if (fingerprint)
88
+ update(cwd, session, entry => ({ ...entry, reviewed: fingerprint }));
89
+ }
53
90
  /** This stop's attempt number: blocks so far plus one. */
54
91
  export function nextStopAttempt(cwd, session) {
55
92
  return update(cwd, session, entry => ({ ...entry, attempts: (entry.attempts ?? 0) + 1 })).attempts ?? 1;
@@ -1,13 +1,18 @@
1
1
  /**
2
- * The "before you say done" review: when an agent is about to stop, review what
3
- * the session changed (since its baseline commit, or the uncommitted change when
4
- * none was recorded) and, if it introduced serious findings, tell the agent what
5
- * to fix instead of letting it finish.
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.
6
8
  *
7
- * Only findings that deserve it block: critical ones, and high ones that are
8
- * proven (the semantic engine traced them) or security findings. A high
9
- * heuristic (a regex that sees `fetch` without `.catch`) is not enough to hold
10
- * an agent back; callers also cap the number of attempts.
9
+ * Only findings that deserve it block: critical ones, high ones that are proven
10
+ * (the semantic engine traced them) or security findings, findings the change added that are
11
+ * certain from the code alone (MUST_FIX: dead code, offset paging, an unbounded window, a
12
+ * duplicate function), and on a branch a merge conflict with main or a mention of a file the
13
+ * branch deleted (review/branch-checks.ts).
14
+ * A high heuristic (a regex that sees `fetch` without `.catch`) is not enough to
15
+ * hold an agent back; callers also cap the number of attempts.
11
16
  */
12
17
  import type { Config, Failure } from '../types/index.js';
13
18
  import { type ReviewTaskItem } from '../review/review-task.js';
@@ -20,10 +25,12 @@ export interface StopDecision {
20
25
  /** Every finding on changed lines, and the files the review covered (for fix capture). */
21
26
  findings: Failure[];
22
27
  reviewedFiles: string[];
28
+ /** What the review was measured against, e.g. `origin/main @ 1a2b3c4` or `uncommitted work`. */
29
+ against: string;
23
30
  }
24
- /** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
25
- export declare function stopReview(cwd: string, config: Config, attempt: number, since?: string): Promise<StopDecision>;
31
+ /** `sessionBaseline`: the commit the session started from, used on main (session-state.ts). */
32
+ export declare function stopReview(cwd: string, config: Config, attempt: number, sessionBaseline?: string): Promise<StopDecision>;
26
33
  export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
27
- /** Critical, or high and either proven by the semantic engine or a security finding. */
34
+ /** Critical; high and either proven by the semantic engine or a security finding; or a certain, local finding the change added (MUST_FIX). */
28
35
  export declare function blocksStop(finding: Failure): boolean;
29
36
  export declare function stopMessage(findings: Failure[], attempt: number): string;
@@ -1,16 +1,30 @@
1
1
  import { reviewChange } from '../review/review.js';
2
2
  import { buildReviewTask } from '../review/review-task.js';
3
3
  import { diffFromGit } from '../review/git-diff.js';
4
+ import { branchBase } from '../gates/logic-drift-git-base.js';
5
+ import { branchFailures } from '../review/branch-checks.js';
4
6
  export const STOP_MAX_ATTEMPTS = 3;
5
7
  const MAX_LISTED = 8;
6
- /** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
7
- export async function stopReview(cwd, config, attempt, since) {
8
- const source = since ? { mode: 'since', commit: since } : { mode: 'working' };
8
+ /** The branch since it left main; on main, what the session changed since its baseline (session-state.ts). */
9
+ function stopSource(cwd, sessionBaseline) {
10
+ const branch = branchBase(cwd);
11
+ if (branch && !branch.onMain) {
12
+ return { source: { mode: 'since', commit: branch.base }, against: `${branch.mainRef.replace(/^refs\/(remotes\/|heads\/)/, '')} @ ${branch.base.slice(0, 7)}` };
13
+ }
14
+ return sessionBaseline
15
+ ? { source: { mode: 'since', commit: sessionBaseline }, against: `this session's start @ ${sessionBaseline.slice(0, 7)}` }
16
+ : { source: { mode: 'working' }, against: 'uncommitted work' };
17
+ }
18
+ /** `sessionBaseline`: the commit the session started from, used on main (session-state.ts). */
19
+ export async function stopReview(cwd, config, attempt, sessionBaseline) {
20
+ const { source, against } = stopSource(cwd, sessionBaseline);
9
21
  const diff = diffFromGit(cwd, source);
10
22
  const result = await reviewChange({ cwd, config, diff, source });
11
- const blocking = result.findings.filter(blocksStop);
23
+ const branch = branchBase(cwd);
24
+ const whole = branch && !branch.onMain ? branchFailures(cwd, branch.base, branch.mainRef, config) : [];
25
+ const blocking = [...result.findings.filter(blocksStop), ...whole];
12
26
  const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
13
- const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
27
+ const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines), against };
14
28
  if (blocking.length === 0 && unreviewed.length === 0)
15
29
  return { block: false, message: '', blocking: 0, ...reviewed };
16
30
  const message = [
@@ -27,10 +41,12 @@ export function reviewAckMessage(items, attempt) {
27
41
  ...listed,
28
42
  ].join('\n');
29
43
  }
30
- /** Critical, or high and either proven by the semantic engine or a security finding. */
44
+ /** Certain from the code alone, and quick to fix (each measured with no false alarms before it blocks). */
45
+ const MUST_FIX = new Set(['unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function']);
46
+ /** Critical; high and either proven by the semantic engine or a security finding; or a certain, local finding the change added (MUST_FIX). */
31
47
  export function blocksStop(finding) {
32
48
  const severity = (finding.severity || 'medium');
33
- if (severity === 'critical')
49
+ if (severity === 'critical' || MUST_FIX.has(finding.id))
34
50
  return true;
35
51
  return severity === 'high' && (finding.verified === true || finding.provenance === 'security');
36
52
  }
@@ -41,7 +57,7 @@ export function stopMessage(findings, attempt) {
41
57
  });
42
58
  const more = findings.length > MAX_LISTED ? [`- …and ${findings.length - MAX_LISTED} more (run \`rigour review\`).`] : [];
43
59
  return [
44
- `Rigour review found ${findings.length} serious issue(s) on the lines you changed (attempt ${attempt} of ${STOP_MAX_ATTEMPTS}).`,
60
+ `Rigour review found ${findings.length} issue(s) to fix in what this branch changed (attempt ${attempt} of ${STOP_MAX_ATTEMPTS}).`,
45
61
  'Fix them before finishing, or explain why a finding is wrong:',
46
62
  ...listed,
47
63
  ...more,
@@ -2,7 +2,7 @@
2
2
  * Hook configuration templates for each AI coding tool.
3
3
  *
4
4
  * Each template generates the tool-native config format:
5
- * - Claude Code: .claude/settings.json (PostToolUse matcher)
5
+ * - Claude Code: .claude/settings.json (PostToolUse checks, Stop review, PreToolUse push gate)
6
6
  * - Cursor: .cursor/hooks.json (afterFileEdit event)
7
7
  * - Cline: .clinerules/hooks/PostToolUse (executable script)
8
8
  * - Windsurf: .windsurf/hooks.json (post_write_code event)
@@ -15,6 +15,13 @@
15
15
  *
16
16
  */
17
17
  import type { HookTool } from './types.js';
18
+ /**
19
+ * The push gate for a Bash hook: a shell check passes every command that is not a git push straight
20
+ * through, so the agent's other commands never wait for Rigour to start.
21
+ */
22
+ export declare function pushGateShell(rigourPushCommand: string): string;
23
+ /** The `rigour hooks push` command matching a `rigour hooks check` command, if the checker is the CLI. */
24
+ export declare function pushCommandFor(checkerCommand: string): string | undefined;
18
25
  /** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
19
26
  export declare function stopCommandFor(checkerCommand: string, tool: 'claude' | 'cursor'): string | undefined;
20
27
  export interface GeneratedHookFile {
@@ -2,7 +2,7 @@
2
2
  * Hook configuration templates for each AI coding tool.
3
3
  *
4
4
  * Each template generates the tool-native config format:
5
- * - Claude Code: .claude/settings.json (PostToolUse matcher)
5
+ * - Claude Code: .claude/settings.json (PostToolUse checks, Stop review, PreToolUse push gate)
6
6
  * - Cursor: .cursor/hooks.json (afterFileEdit event)
7
7
  * - Cline: .clinerules/hooks/PostToolUse (executable script)
8
8
  * - Windsurf: .windsurf/hooks.json (post_write_code event)
@@ -17,6 +17,19 @@
17
17
  import { STOP_MAX_ATTEMPTS } from './stop-review.js';
18
18
  /** Seconds Claude Code waits for the stop review before letting the agent stop. */
19
19
  const STOP_HOOK_TIMEOUT_S = 120;
20
+ /** Seconds Claude Code waits for the push gate: the project's tests and the reviewer can take minutes. */
21
+ const PUSH_HOOK_TIMEOUT_S = 1800;
22
+ /**
23
+ * The push gate for a Bash hook: a shell check passes every command that is not a git push straight
24
+ * through, so the agent's other commands never wait for Rigour to start.
25
+ */
26
+ export function pushGateShell(rigourPushCommand) {
27
+ return `sh -c 'payload=$(cat); case "$payload" in *git*push*) printf "%s" "$payload" | ${rigourPushCommand.replace(/'/g, `'\\''`)} ;; esac'`;
28
+ }
29
+ /** The `rigour hooks push` command matching a `rigour hooks check` command, if the checker is the CLI. */
30
+ export function pushCommandFor(checkerCommand) {
31
+ return /\bhooks check$/.test(checkerCommand.trim()) ? pushGateShell(`${checkerCommand.trim().replace(/hooks check$/, 'hooks push')} --stdin`) : undefined;
32
+ }
20
33
  /** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
21
34
  export function stopCommandFor(checkerCommand, tool) {
22
35
  return /\bhooks check$/.test(checkerCommand.trim()) ? `${checkerCommand.trim().replace(/hooks check$/, 'hooks stop')} --tool ${tool}` : undefined;
@@ -52,10 +65,14 @@ function generateClaudeHooks(checkerCommand) {
52
65
  ]
53
66
  }
54
67
  ],
55
- // Before the agent finishes: review the uncommitted change, as `rigour hooks init` does.
68
+ // Before the agent finishes: review the branch against main, as `rigour hooks init` does.
56
69
  ...(stopCommandFor(checkerCommand, 'claude')
57
70
  ? { Stop: [{ hooks: [{ type: "command", command: stopCommandFor(checkerCommand, 'claude'), timeout: STOP_HOOK_TIMEOUT_S }] }] }
58
71
  : {}),
72
+ // Before an agent's git push: the push gate.
73
+ ...(pushCommandFor(checkerCommand)
74
+ ? { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: pushCommandFor(checkerCommand), timeout: PUSH_HOOK_TIMEOUT_S }] }] }
75
+ : {}),
59
76
  }
60
77
  };
61
78
  return [
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { generateHookFiles, stopCommandFor } from './templates.js';
2
+ import { execFileSync } from 'child_process';
3
+ import { generateHookFiles, pushCommandFor, pushGateShell, stopCommandFor } from './templates.js';
3
4
  const CHECKER = 'npx @rigour-labs/cli@6.5.0 hooks check';
4
5
  describe('hook templates', () => {
5
6
  it('reviews before the agent stops when the checker is the CLI, like `rigour hooks init`', () => {
@@ -8,6 +9,16 @@ describe('hook templates', () => {
8
9
  const cursor = JSON.parse(generateHookFiles('cursor', CHECKER).find(f => f.path.endsWith('hooks.json')).content);
9
10
  expect(cursor.hooks.stop[0]).toEqual({ command: 'npx @rigour-labs/cli@6.5.0 hooks stop --tool cursor', loop_limit: 3 });
10
11
  });
12
+ it("gates the agent's git push when the checker is the CLI", () => {
13
+ const claude = JSON.parse(generateHookFiles('claude', CHECKER)[0].content);
14
+ expect(claude.hooks.PreToolUse[0]).toEqual({ matcher: 'Bash', hooks: [{ type: 'command', command: expect.stringContaining('npx @rigour-labs/cli@6.5.0 hooks push --stdin'), timeout: 1800 }] });
15
+ expect(pushCommandFor('node ./my-checker.js')).toBeUndefined();
16
+ });
17
+ it.skipIf(process.platform === 'win32')('starts the push gate only for a command that mentions git push', () => {
18
+ const run = (command) => execFileSync('sh', ['-c', pushGateShell('cat > /dev/null; echo gated; exit 2').replace(/^sh -c /, '').slice(1, -1)], { input: JSON.stringify({ tool_input: { command } }), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
19
+ expect(run('npm test')).toBe('');
20
+ expect(() => run('git push -u origin main')).toThrow(); // the gate's exit 2 reaches the agent
21
+ });
11
22
  it('adds no stop hook for a checker that is not the CLI', () => {
12
23
  expect(stopCommandFor('node ./my-checker.js', 'claude')).toBeUndefined();
13
24
  const claude = JSON.parse(generateHookFiles('claude', 'node ./my-checker.js')[0].content);
package/dist/index.d.ts CHANGED
@@ -20,6 +20,10 @@ 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
22
  export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
23
+ export { runToolchain, type ToolResult, type ToolStatus } from './review/toolchain.js';
24
+ export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
25
+ export { runReviewer, reviewerBlocks, type ReviewerResult, type ReviewerVerdict } from './review/reviewer.js';
26
+ export { branchBase, type BranchBase } from './gates/logic-drift-git-base.js';
23
27
  export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
24
28
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
25
29
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type FunctionRisk, type RiskSignals } from './deep/risk.js';
package/dist/index.js CHANGED
@@ -20,6 +20,10 @@ 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
22
  export { buildQualityReceipt, receiptReport } from './review/receipt.js';
23
+ export { runToolchain } from './review/toolchain.js';
24
+ export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
25
+ export { runReviewer, reviewerBlocks } from './review/reviewer.js';
26
+ export { branchBase } from './gates/logic-drift-git-base.js';
23
27
  export { acknowledgeReview } from './review/review-ack.js';
24
28
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
25
29
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './deep/risk.js';
@@ -0,0 +1,16 @@
1
+ import type { Config, Failure } from '../types/index.js';
2
+ import type { DiffSource } from './git-diff.js';
3
+ /** The commit the change is measured against, or undefined when there is none (a bare diff, a repository without commits). */
4
+ export declare function baseCommit(cwd: string, source: DiffSource | undefined): string | undefined;
5
+ export interface BaseRun {
6
+ failures: Failure[];
7
+ /** Changed files the base did not have. */
8
+ added: Set<string>;
9
+ }
10
+ /** The rules' findings on the repository at `commit`, with the files the change adds as they are now; records nothing. */
11
+ export declare function baseFindings(cwd: string, config: Config, commit: string, files: string[]): Promise<BaseRun>;
12
+ /** Split head findings into those the change introduced and those the base already had. */
13
+ export declare function splitIntroduced(head: Failure[], base: BaseRun): {
14
+ introduced: Failure[];
15
+ preexisting: Failure[];
16
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Findings a change did not introduce.
3
+ *
4
+ * A function that was already too complex, or a file that already broke a naming convention, is
5
+ * reported on every review that touches it, and a reviewer learns to skip Rigour's output. The
6
+ * same rules run on the repository as it was at the base (`git archive`, read-only, into a temporary
7
+ * folder; about half a second for a few thousand files), so rules that look across the whole
8
+ * repository see the same picture on both sides. A finding the base already had is counted instead
9
+ * of reported, even when the change moved its numbers (a function at complexity 105 that reaches
10
+ * 109 is the same old problem). Files the change adds are copied in as they are now, so both sides
11
+ * look at the same set of files; findings in those files always count as introduced. Model
12
+ * findings are never compared: the model reviews only the change.
13
+ */
14
+ import { spawnSync } from 'child_process';
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ import path from 'path';
18
+ import { GateRunner } from '../gates/runner.js';
19
+ import { normalizeScopePatterns } from '../utils/scope.js';
20
+ /** The commit the change is measured against, or undefined when there is none (a bare diff, a repository without commits). */
21
+ export function baseCommit(cwd, source) {
22
+ if (!source)
23
+ return undefined;
24
+ const rev = source.mode === 'base' ? git(cwd, ['merge-base', source.base, 'HEAD'])
25
+ : source.mode === 'since' ? git(cwd, ['rev-parse', '--verify', `${source.commit}^{commit}`])
26
+ : git(cwd, ['rev-parse', '--verify', 'HEAD']);
27
+ return rev?.trim() || undefined;
28
+ }
29
+ /** The rules' findings on the repository at `commit`, with the files the change adds as they are now; records nothing. */
30
+ export async function baseFindings(cwd, config, commit, files) {
31
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-base-'));
32
+ try {
33
+ extractTree(cwd, commit, dir);
34
+ const added = new Set(files.filter(file => !fs.existsSync(path.join(dir, file))));
35
+ for (const file of added)
36
+ copyCurrent(cwd, file, dir);
37
+ if (added.size === files.length)
38
+ return { failures: [], added };
39
+ const modules = path.join(cwd, 'node_modules');
40
+ if (fs.existsSync(modules))
41
+ fs.symlinkSync(modules, path.join(dir, 'node_modules'), 'junction');
42
+ const report = await new GateRunner(config).run(dir, await normalizeScopePatterns(dir, files), undefined, { record: false });
43
+ return { failures: report.failures, added };
44
+ }
45
+ finally {
46
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
47
+ }
48
+ }
49
+ /** Split head findings into those the change introduced and those the base already had. */
50
+ export function splitIntroduced(head, base) {
51
+ const remaining = new Map();
52
+ for (const f of base.failures)
53
+ remaining.set(identity(f), (remaining.get(identity(f)) ?? 0) + 1);
54
+ const introduced = [];
55
+ const preexisting = [];
56
+ for (const f of head) {
57
+ const key = identity(f);
58
+ const left = remaining.get(key) ?? 0;
59
+ if (left > 0 && !base.added.has(f.files?.[0] ?? '')) {
60
+ remaining.set(key, left - 1);
61
+ preexisting.push(f);
62
+ }
63
+ else {
64
+ introduced.push(f);
65
+ }
66
+ }
67
+ return { introduced, preexisting };
68
+ }
69
+ /** A finding without the numbers that move when unrelated lines are added: line numbers, counts, scores. */
70
+ function identity(f) {
71
+ const strip = (text) => (text ?? '').replace(/\d+/g, '#');
72
+ return [f.id, f.files?.[0] ?? '', strip(f.title), strip(f.details)].join('\u0000');
73
+ }
74
+ /** The tree at `commit`, written into `dir` (through a tar file: no buffer limit, and tar ships with Windows 10+). */
75
+ function extractTree(cwd, commit, dir) {
76
+ const archive = path.join(dir, '.rigour-base.tar');
77
+ const made = spawnSync('git', ['archive', '--format=tar', '-o', archive, commit], { cwd });
78
+ if (made.status !== 0)
79
+ throw new Error(`git archive ${commit} failed: ${String(made.stderr).trim()}`);
80
+ const unpacked = spawnSync('tar', ['-xf', archive, '-C', dir]);
81
+ fs.rmSync(archive, { force: true });
82
+ if (unpacked.status !== 0)
83
+ throw new Error(`tar failed: ${String(unpacked.stderr).trim()}`);
84
+ }
85
+ function copyCurrent(cwd, file, dir) {
86
+ const source = path.join(cwd, file);
87
+ if (!fs.existsSync(source))
88
+ return;
89
+ const target = path.join(dir, file);
90
+ fs.mkdirSync(path.dirname(target), { recursive: true });
91
+ fs.copyFileSync(source, target);
92
+ }
93
+ function git(cwd, args) {
94
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
95
+ return result.status === 0 ? result.stdout : undefined;
96
+ }
@@ -0,0 +1 @@
1
+ export {};