@rigour-labs/core 6.6.6 → 6.7.0

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 (133) hide show
  1. package/README.md +16 -92
  2. package/dist/environment.test.js +0 -4
  3. package/dist/gates/agent-team.d.ts +0 -2
  4. package/dist/gates/agent-team.js +0 -8
  5. package/dist/gates/checkpoint.d.ts +0 -1
  6. package/dist/gates/checkpoint.js +0 -1
  7. package/dist/gates/context.d.ts +0 -4
  8. package/dist/gates/context.js +7 -17
  9. package/dist/gates/deep-analysis.d.ts +0 -1
  10. package/dist/gates/deep-runner.js +0 -1
  11. package/dist/gates/frontend-secret-exposure.test.js +2 -2
  12. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  13. package/dist/gates/hallucinated-imports/index.js +2 -2
  14. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  15. package/dist/gates/hallucinated-imports.test.js +10 -0
  16. package/dist/gates/runner.js +3 -4
  17. package/dist/gates/test-quality-matchers.d.ts +0 -4
  18. package/dist/gates/test-quality-matchers.js +0 -7
  19. package/dist/gates/test-quality.d.ts +0 -2
  20. package/dist/gates/test-quality.js +1 -10
  21. package/dist/hooks/checker.js +1 -1
  22. package/dist/hooks/index.d.ts +1 -2
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/types.d.ts +0 -14
  25. package/dist/hooks/types.js +1 -15
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +6 -1
  28. package/dist/inference/cloud-provider.d.ts +2 -0
  29. package/dist/inference/cloud-provider.js +16 -6
  30. package/dist/inference/cloud-provider.test.js +21 -0
  31. package/dist/pattern-index/embeddings.d.ts +0 -5
  32. package/dist/pattern-index/embeddings.js +11 -4
  33. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  34. package/dist/pattern-index/semantic-runtime.js +46 -0
  35. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  36. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  37. package/dist/review/backtest-judges.d.ts +38 -0
  38. package/dist/review/backtest-judges.js +63 -0
  39. package/dist/review/backtest-judges.test.d.ts +1 -0
  40. package/dist/review/backtest-judges.test.js +32 -0
  41. package/dist/review/backtest.d.ts +9 -4
  42. package/dist/review/backtest.js +19 -9
  43. package/dist/review/backtest.test.js +2 -2
  44. package/dist/review/baseline.js +2 -1
  45. package/dist/review/dead-code.test.js +7 -0
  46. package/dist/review/fix-scope.d.ts +21 -0
  47. package/dist/review/fix-scope.js +94 -0
  48. package/dist/review/fix-scope.test.d.ts +1 -0
  49. package/dist/review/fix-scope.test.js +104 -0
  50. package/dist/review/lint-overlay.js +1 -1
  51. package/dist/review/orphan-files.js +3 -2
  52. package/dist/review/partial-wiring.js +5 -3
  53. package/dist/review/query-patterns.js +3 -2
  54. package/dist/review/review.js +2 -1
  55. package/dist/review/reviewer/adapters.d.ts +23 -7
  56. package/dist/review/reviewer/adapters.js +32 -7
  57. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  58. package/dist/review/reviewer/adapters.test.js +23 -0
  59. package/dist/review/reviewer/background.d.ts +8 -1
  60. package/dist/review/reviewer/background.js +34 -9
  61. package/dist/review/reviewer/background.test.js +16 -2
  62. package/dist/review/reviewer/consensus.d.ts +36 -0
  63. package/dist/review/reviewer/consensus.js +144 -0
  64. package/dist/review/reviewer/context.d.ts +75 -0
  65. package/dist/review/reviewer/context.js +140 -0
  66. package/dist/review/reviewer/context.test.d.ts +1 -0
  67. package/dist/review/reviewer/context.test.js +18 -0
  68. package/dist/review/reviewer/exec.js +6 -2
  69. package/dist/review/reviewer/exec.test.d.ts +1 -0
  70. package/dist/review/reviewer/exec.test.js +23 -0
  71. package/dist/review/reviewer/inputs.d.ts +2 -0
  72. package/dist/review/reviewer/inputs.js +5 -1
  73. package/dist/review/reviewer/panel.d.ts +67 -0
  74. package/dist/review/reviewer/panel.js +101 -0
  75. package/dist/review/reviewer/panel.test.d.ts +1 -0
  76. package/dist/review/reviewer/panel.test.js +110 -0
  77. package/dist/review/reviewer/prompt.d.ts +11 -0
  78. package/dist/review/reviewer/prompt.js +26 -4
  79. package/dist/review/reviewer/settings.d.ts +58 -0
  80. package/dist/review/reviewer/settings.js +152 -0
  81. package/dist/review/reviewer/settings.test.d.ts +1 -0
  82. package/dist/review/reviewer/settings.test.js +57 -0
  83. package/dist/review/reviewer/store.d.ts +25 -2
  84. package/dist/review/reviewer/store.js +43 -1
  85. package/dist/review/reviewer/usage.d.ts +2 -0
  86. package/dist/review/reviewer/usage.js +28 -0
  87. package/dist/review/reviewer/usage.test.d.ts +1 -0
  88. package/dist/review/reviewer/usage.test.js +14 -0
  89. package/dist/review/reviewer/verdict.d.ts +15 -1
  90. package/dist/review/reviewer/verdict.js +17 -7
  91. package/dist/review/reviewer.d.ts +45 -2
  92. package/dist/review/reviewer.js +186 -25
  93. package/dist/review/reviewer.test.js +127 -12
  94. package/dist/review/test-files.d.ts +5 -1
  95. package/dist/review/test-files.js +6 -2
  96. package/dist/review/toolchain.js +10 -3
  97. package/dist/review/toolchain.test.js +6 -3
  98. package/dist/review/typed/program.d.ts +0 -1
  99. package/dist/review/typed/program.js +3 -3
  100. package/dist/review/typed/redundancy.js +43 -4
  101. package/dist/review/typed/redundancy.test.js +58 -0
  102. package/dist/review/typed/schema-nullability.d.ts +6 -0
  103. package/dist/review/typed/schema-nullability.js +218 -0
  104. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  105. package/dist/review/typed/schema-nullability.test.js +63 -0
  106. package/dist/review/unused-exports.js +5 -6
  107. package/dist/semantic/learn/types.d.ts +2 -2
  108. package/dist/services/adaptive-thresholds.d.ts +2 -52
  109. package/dist/services/adaptive-thresholds.js +2 -147
  110. package/dist/services/adaptive-thresholds.test.js +1 -81
  111. package/dist/settings.d.ts +14 -6
  112. package/dist/settings.js +0 -12
  113. package/dist/storage/team-diagnostics.js +1 -1
  114. package/dist/storage/team-schema.d.ts +1 -1
  115. package/dist/storage/team-schema.js +2 -0
  116. package/dist/storage/team-store.js +26 -3
  117. package/dist/storage/team-sync-scope.test.js +35 -2
  118. package/dist/telemetry/telemetry.d.ts +5 -0
  119. package/dist/telemetry/telemetry.js +11 -1
  120. package/dist/templates/paradigms.js +0 -8
  121. package/dist/templates/presets.d.ts +0 -1
  122. package/dist/templates/presets.js +0 -23
  123. package/dist/templates/universal-config.js +8 -45
  124. package/dist/types/index.d.ts +108 -566
  125. package/dist/types/index.js +41 -66
  126. package/dist/types/index.test.d.ts +1 -0
  127. package/dist/types/index.test.js +33 -0
  128. package/dist/utils/command-line.d.ts +8 -0
  129. package/dist/utils/command-line.js +8 -0
  130. package/dist/utils/command-line.test.d.ts +1 -0
  131. package/dist/utils/command-line.test.js +8 -0
  132. package/dist/utils/profile.js +1 -1
  133. package/package.json +6 -7
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a panel agrees, as pure functions: when two judges' items are the same finding (similarity),
3
+ * the minimum-cost one-to-one assignment (the Hungarian algorithm), the grouping of every judge's
4
+ * items into findings, and the majority rule that decides a finding from every judge's call.
5
+ * Deterministic: inputs sorted, ties broken on file, line and id. panel.ts runs the judges around it.
6
+ */
7
+ import type { OpenItem } from './verdict.js';
8
+ export type Call = 'raised' | 'confirm' | 'refute' | 'unsure' | 'not asked';
9
+ export type PanelStatus = 'confirmed' | 'disputed' | 'dropped';
10
+ /** Similarity at or above which two items are the same finding. */
11
+ export declare const MATCH_THRESHOLD = 0.5;
12
+ export declare const order: (a: OpenItem, b: OpenItem) => number;
13
+ /** Shared words over all words (Jaccard) of two items' issue and consequence. */
14
+ export declare function textSimilarity(a: OpenItem, b: OpenItem): number;
15
+ /** 0 for different files; otherwise wording, line distance and class, weighted. A far-apart pair needs close wording. */
16
+ export declare function similarity(a: OpenItem, b: OpenItem): number;
17
+ /** One finding as the panel sees it: the item each judge who raised it wrote, in judge order. */
18
+ export interface Cluster {
19
+ members: Array<{
20
+ judge: string;
21
+ item: OpenItem;
22
+ }>;
23
+ }
24
+ /**
25
+ * The judges' items grouped into findings. Judges are taken in order; each judge's items are
26
+ * assigned one-to-one to the findings so far by the Hungarian algorithm (a finding scores as its
27
+ * closest member), never first-fit. An item left over that a finding already holding this judge
28
+ * resembles joins it (one judge split what another named once); any other leftover is a new finding.
29
+ */
30
+ export declare function clusterItems(judges: string[], items: OpenItem[][]): Cluster[];
31
+ /**
32
+ * Status from every judge's call. Confirmed: a strict majority raised it or confirmed it with
33
+ * evidence. Dropped: refutations with evidence at least match the support and no judge is unsure.
34
+ * Disputed otherwise. With two judges: both raised it, or the other confirmed it; refuted, dropped.
35
+ */
36
+ export declare function statusOf(calls: Record<string, Call>, judges: number): PanelStatus;
@@ -0,0 +1,144 @@
1
+ /** Similarity at or above which two items are the same finding. */
2
+ export const MATCH_THRESHOLD = 0.5;
3
+ /** Below this share of words, two items are the same finding only on the same line and in the same class. */
4
+ const MIN_SHARED_WORDS = 0.15;
5
+ const LINE_WINDOW = 5;
6
+ const STOP = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from', 'when', 'into', 'are', 'not', 'but', 'its', 'has', 'have', 'every', 'each', 'one', 'can']);
7
+ export const order = (a, b) => (a.file ?? '').localeCompare(b.file ?? '') || (a.line ?? 0) - (b.line ?? 0) || a.id.localeCompare(b.id);
8
+ /** Shared words over all words (Jaccard) of two items' issue and consequence. */
9
+ export function textSimilarity(a, b) {
10
+ const wa = words(a);
11
+ const wb = words(b);
12
+ const shared = [...wa].filter(w => wb.has(w)).length;
13
+ return wa.size + wb.size - shared ? shared / (wa.size + wb.size - shared) : 0;
14
+ }
15
+ function words(item) {
16
+ const text = `${item.issue} ${item.consequence ?? ''}`.toLowerCase();
17
+ return new Set((text.match(/[a-z_][a-z0-9_]{2,}/g) ?? []).filter(w => !STOP.has(w)));
18
+ }
19
+ /** 0 for different files; otherwise wording, line distance and class, weighted. A far-apart pair needs close wording. */
20
+ export function similarity(a, b) {
21
+ if (!a.file || a.file !== b.file)
22
+ return 0;
23
+ const text = textSimilarity(a, b);
24
+ const distance = a.line !== undefined && b.line !== undefined ? Math.abs(a.line - b.line) : undefined;
25
+ const line = distance === undefined ? 0.5 : distance <= LINE_WINDOW ? 1 - distance / (LINE_WINDOW + 1) : 0;
26
+ if (line === 0 && text < 0.5)
27
+ return 0;
28
+ if (text < MIN_SHARED_WORDS && !(distance === 0 && a.class === b.class))
29
+ return 0;
30
+ return 0.4 * text + 0.35 * line + 0.25 * (a.class === b.class ? 1 : 0);
31
+ }
32
+ /**
33
+ * Minimum-cost assignment of rows to columns (Hungarian algorithm with potentials, O(n²m)).
34
+ * `cost` is rows × columns with rows ≤ columns; returns the column of each row.
35
+ */
36
+ function assign(cost) {
37
+ const n = cost.length;
38
+ const m = n ? cost[0].length : 0;
39
+ const u = new Array(n + 1).fill(0);
40
+ const v = new Array(m + 1).fill(0);
41
+ const p = new Array(m + 1).fill(0);
42
+ const way = new Array(m + 1).fill(0);
43
+ for (let i = 1; i <= n; i++) {
44
+ p[0] = i;
45
+ let j0 = 0;
46
+ const minv = new Array(m + 1).fill(Infinity);
47
+ const used = new Array(m + 1).fill(false);
48
+ do {
49
+ used[j0] = true;
50
+ const i0 = p[j0];
51
+ let delta = Infinity;
52
+ let j1 = 0;
53
+ for (let j = 1; j <= m; j++) {
54
+ if (used[j])
55
+ continue;
56
+ const cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
57
+ if (cur < minv[j]) {
58
+ minv[j] = cur;
59
+ way[j] = j0;
60
+ }
61
+ if (minv[j] < delta) {
62
+ delta = minv[j];
63
+ j1 = j;
64
+ }
65
+ }
66
+ for (let j = 0; j <= m; j++) {
67
+ if (used[j]) {
68
+ u[p[j]] += delta;
69
+ v[j] -= delta;
70
+ }
71
+ else
72
+ minv[j] -= delta;
73
+ }
74
+ j0 = j1;
75
+ } while (p[j0] !== 0);
76
+ do {
77
+ const j1 = way[j0];
78
+ p[j0] = p[j1];
79
+ j0 = j1;
80
+ } while (j0);
81
+ }
82
+ const column = new Array(n).fill(-1);
83
+ for (let j = 1; j <= m; j++)
84
+ if (p[j])
85
+ column[p[j] - 1] = j - 1;
86
+ return column;
87
+ }
88
+ const clusterSimilarity = (cluster, item) => Math.max(...cluster.members.map(m => similarity(m.item, item)));
89
+ /**
90
+ * The judges' items grouped into findings. Judges are taken in order; each judge's items are
91
+ * assigned one-to-one to the findings so far by the Hungarian algorithm (a finding scores as its
92
+ * closest member), never first-fit. An item left over that a finding already holding this judge
93
+ * resembles joins it (one judge split what another named once); any other leftover is a new finding.
94
+ */
95
+ export function clusterItems(judges, items) {
96
+ const clusters = [];
97
+ judges.forEach((judge, j) => {
98
+ const mine = [...items[j]].sort(order);
99
+ const open = clusters.map((c, i) => ({ c, i })).filter(x => !x.c.members.some(m => m.judge === judge));
100
+ const sims = mine.map(item => open.map(x => clusterSimilarity(x.c, item)));
101
+ const placed = new Set();
102
+ if (mine.length && open.length) {
103
+ const transpose = mine.length > open.length;
104
+ const matrix = transpose ? open.map((_, k) => mine.map((__, i) => sims[i][k])) : sims;
105
+ const column = assign(matrix.map(row => row.map(s => (s >= MATCH_THRESHOLD ? 1 - s : 1))));
106
+ column.forEach((col, row) => {
107
+ if (col < 0)
108
+ return;
109
+ const [i, k] = transpose ? [col, row] : [row, col];
110
+ if (sims[i][k] < MATCH_THRESHOLD)
111
+ return;
112
+ open[k].c.members.push({ judge, item: mine[i] });
113
+ placed.add(i);
114
+ });
115
+ }
116
+ mine.forEach((item, i) => {
117
+ if (placed.has(i))
118
+ return;
119
+ // Only a finding another judge also holds: a judge's own two findings are never merged into one.
120
+ const home = clusters.filter(c => c.members.some(m => m.judge === judge) && c.members.some(m => m.judge !== judge)).map(c => ({ c, s: clusterSimilarity(c, item) }))
121
+ .filter(x => x.s >= MATCH_THRESHOLD).sort((a, b) => b.s - a.s || order(a.c.members[0].item, b.c.members[0].item))[0];
122
+ if (home)
123
+ home.c.members.push({ judge, item });
124
+ else
125
+ clusters.push({ members: [{ judge, item }] });
126
+ });
127
+ });
128
+ return clusters;
129
+ }
130
+ /**
131
+ * Status from every judge's call. Confirmed: a strict majority raised it or confirmed it with
132
+ * evidence. Dropped: refutations with evidence at least match the support and no judge is unsure.
133
+ * Disputed otherwise. With two judges: both raised it, or the other confirmed it; refuted, dropped.
134
+ */
135
+ export function statusOf(calls, judges) {
136
+ const values = Object.values(calls);
137
+ const support = values.filter(c => c === 'raised' || c === 'confirm').length;
138
+ const refuted = values.filter(c => c === 'refute').length;
139
+ if (support > judges / 2)
140
+ return 'confirmed';
141
+ if (refuted && refuted >= support && !values.includes('unsure') && !values.includes('not asked'))
142
+ return 'dropped';
143
+ return 'disputed';
144
+ }
@@ -0,0 +1,75 @@
1
+ import type { RouterPolicy } from '../../deep/router.js';
2
+ import type { PanelItem } from './panel.js';
3
+ import { type Exec } from './exec.js';
4
+ import type { OpenItem } from './verdict.js';
5
+ export declare const REVIEW_DISMISSALS: string;
6
+ export interface ReviewDismissal {
7
+ id: string;
8
+ file?: string;
9
+ line?: number;
10
+ class: string;
11
+ issue: string;
12
+ reason: string;
13
+ at: string;
14
+ by?: string;
15
+ }
16
+ export declare function readReviewDismissals(cwd: string): ReviewDismissal[];
17
+ /**
18
+ * The dismissal an item repeats: the same id, or the same finding re-worded: the same file and
19
+ * class, within a few lines, and mostly the same words. A different bug on the same line, or a
20
+ * finding that only shares the file and class, is not dismissed.
21
+ */
22
+ export declare function dismissedAs(item: OpenItem, dismissals: ReviewDismissal[]): ReviewDismissal | undefined;
23
+ export interface ContextInput {
24
+ cwd: string;
25
+ /** Where .rigour lives: the main checkout when cwd is the background reviewer's worktree. */
26
+ stateRoot: string;
27
+ /** The team's dismissals, when it allows them (empty otherwise). */
28
+ dismissals: ReviewDismissal[];
29
+ diff: string;
30
+ router: RouterPolicy | undefined;
31
+ /** The previous verdict's panel decisions, and the files changed since it. */
32
+ previousPanel: PanelItem[] | undefined;
33
+ touched: Set<string>;
34
+ /** Docs that name the changed code (relatedDocs). */
35
+ docs: Array<{
36
+ doc: string;
37
+ names: string[];
38
+ }>;
39
+ /** Findings Rigour's checks already report on this change, one line each. */
40
+ checks: string[];
41
+ }
42
+ /** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
43
+ export declare function reviewerInputs(review: {
44
+ hints: string[];
45
+ findings: Array<{
46
+ files?: string[];
47
+ line?: number;
48
+ title: string;
49
+ }>;
50
+ }): {
51
+ hints: string;
52
+ checks: string[];
53
+ };
54
+ /** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
55
+ export declare function buildContext(input: ContextInput): {
56
+ text: string;
57
+ key: string;
58
+ risky: number | undefined;
59
+ };
60
+ /**
61
+ * Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
62
+ * `git grep` over the docs, never a read of every one, stopping at the first few.
63
+ */
64
+ export declare function relatedDocs(cwd: string, changedFiles: string[], exec?: Exec): Promise<Array<{
65
+ doc: string;
66
+ names: string[];
67
+ }>>;
68
+ /**
69
+ * `rigour dismiss <id>` on a reviewer finding: only where the team allows it (`review.reviewer.dismissals`), with a
70
+ * reason, recorded with who dismissed it. The item is found in this branch's latest verdict.
71
+ */
72
+ export declare function dismissReviewerFinding(cwd: string, id: string, reason: string, allowed: boolean, exec?: Exec): Promise<{
73
+ item?: OpenItem;
74
+ error?: string;
75
+ }>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * What the team already knows, handed to every judge so a review starts from it instead of from
3
+ * nothing: the verified review lessons and the repository rules for the files the change touches,
4
+ * the findings the team settled (dismissed as not a bug, or refuted with evidence in an earlier
5
+ * round on a file nothing has changed since), and the docs that name the changed code. Built
6
+ * deterministically, without a model; its hash is part of the verdict's fingerprint, so a new
7
+ * lesson or dismissal is never answered by a verdict cached before it.
8
+ *
9
+ * The reviewer dismissal store lives here too: `rigour dismiss <id>` on a reviewer finding records
10
+ * the finding itself, so the same finding re-worded on the same file is recognised and never
11
+ * blocks again.
12
+ */
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { createHash } from 'crypto';
16
+ import { buildReviewTask } from '../review-task.js';
17
+ import { reviewedKeys } from '../ledger.js';
18
+ import { textSimilarity } from './consensus.js';
19
+ import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
20
+ import { VerdictStore } from './store.js';
21
+ export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.json');
22
+ const MAX_DOCS = 10;
23
+ const MAX_SETTLED = 40;
24
+ export function readReviewDismissals(cwd) {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(path.join(cwd, REVIEW_DISMISSALS), 'utf8'));
27
+ return Array.isArray(parsed?.entries) ? parsed.entries.filter((e) => typeof e?.id === 'string' && typeof e?.issue === 'string') : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ /** Records "not a bug" for a reviewer finding; idempotent. A human's own review point is never dismissed this way. */
34
+ function dismissReviewItem(cwd, item, reason, by, at = new Date().toISOString()) {
35
+ if (item.kind === 'prior')
36
+ return false;
37
+ const entries = readReviewDismissals(cwd);
38
+ if (entries.some(e => e.id === item.id))
39
+ return true;
40
+ entries.push({ id: item.id, ...(item.file ? { file: item.file } : {}), ...(item.line ? { line: item.line } : {}), class: item.class, issue: item.issue, reason, at, ...(by ? { by } : {}) });
41
+ fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
42
+ fs.writeFileSync(path.join(cwd, REVIEW_DISMISSALS), JSON.stringify({ entries }, null, 2));
43
+ return true;
44
+ }
45
+ /** Close enough in place and wording that a dismissal of one is a dismissal of the other; a different bug nearby never is. */
46
+ const DISMISSAL_LINES = 3;
47
+ const DISMISSAL_WORDS = 0.6;
48
+ /**
49
+ * The dismissal an item repeats: the same id, or the same finding re-worded: the same file and
50
+ * class, within a few lines, and mostly the same words. A different bug on the same line, or a
51
+ * finding that only shares the file and class, is not dismissed.
52
+ */
53
+ export function dismissedAs(item, dismissals) {
54
+ if (item.kind === 'prior')
55
+ return undefined;
56
+ return dismissals.find(d => d.id === item.id) ?? dismissals.find(d => d.file === item.file && d.class === item.class
57
+ && (d.line === undefined || item.line === undefined || Math.abs(d.line - item.line) <= DISMISSAL_LINES)
58
+ && textSimilarity({ ...item, consequence: undefined }, { id: d.id, kind: 'finding', class: d.class, issue: d.issue }) >= DISMISSAL_WORDS);
59
+ }
60
+ /** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
61
+ export function reviewerInputs(review) {
62
+ return { hints: review.hints.join('\n'), checks: review.findings.map(f => `${f.files?.[0] ?? '?'}${f.line ? `:${f.line}` : ''} ${f.title}`) };
63
+ }
64
+ /** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
65
+ export function buildContext(input) {
66
+ const sections = [];
67
+ let task;
68
+ try {
69
+ task = buildReviewTask(input.cwd, input.diff, input.router, undefined, undefined, reviewedKeys(input.stateRoot));
70
+ }
71
+ catch {
72
+ task = undefined;
73
+ }
74
+ if (task?.lessons.length)
75
+ sections.push(`## Lessons this team verified on earlier reviews, for the files this change touches\n${task.lessons.map(l => `- ${l.file}: ${l.text}${l.prs.length ? ` (PR ${l.prs.join(', ')})` : ''}`).join('\n')}`);
76
+ if (task?.rules.length)
77
+ sections.push(`## Repository rules that name what this change touches\n${task.rules.map(r => `- ${r.source}: ${r.text}`).join('\n')}`);
78
+ if (input.checks.length)
79
+ sections.push(`## Already found by Rigour's checks: they block on their own, so do not report them again\n${input.checks.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}`);
80
+ const dismissed = input.dismissals.slice(-MAX_SETTLED).map(d => `- dismissed as not a bug${d.by ? ` by ${d.by}` : ''}: ${where(d)} [${d.class}] ${d.issue} (reason: ${d.reason})`);
81
+ const refuted = (input.previousPanel ?? []).filter(p => p.status === 'dropped' && !!p.item.file && !input.touched.has(p.item.file)).slice(0, MAX_SETTLED)
82
+ .map(p => `- refuted with evidence in the last round: ${where(p.item)} [${p.item.class}] ${p.item.issue} (${(p.cross ?? []).find(c => c.call === 'refute')?.evidence ?? 'evidence in the previous verdict'})`);
83
+ if (dismissed.length || refuted.length)
84
+ sections.push(`## Settled: do not raise these again unless the code now shows something new\n${[...dismissed, ...refuted].join('\n')}`);
85
+ const docs = input.docs;
86
+ if (docs.length)
87
+ sections.push(`## Docs that describe the changed code (read one when its claim matters to a finding)\n${docs.map(d => `- ${d.doc} (names ${d.names.join(', ')})`).join('\n')}`);
88
+ const text = sections.length ? `# What this team already knows\n\n${sections.join('\n\n')}\n` : 'none\n';
89
+ return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined };
90
+ }
91
+ function where(x) {
92
+ return x.file ? `${x.file}${x.line ? `:${x.line}` : ''}` : '(no file)';
93
+ }
94
+ /**
95
+ * Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
96
+ * `git grep` over the docs, never a read of every one, stopping at the first few.
97
+ */
98
+ export async function relatedDocs(cwd, changedFiles, exec = defaultExec) {
99
+ const names = [...new Set(changedFiles.flatMap(file => {
100
+ const stem = path.posix.basename(file).replace(/\.[^.]+$/, '');
101
+ return stem.length >= 5 && !/^(index|types|utils|helpers|config|main)$/.test(stem) ? [file, stem] : [file];
102
+ }))];
103
+ if (!names.length)
104
+ return [];
105
+ const grep = await exec('git', ['grep', '-I', '-F', '-o', ...names.flatMap(name => ['-e', name]), '--', '*.md'], { cwd, timeoutMs: GH_TIMEOUT_MS });
106
+ const found = new Map();
107
+ for (const line of grep.stdout.split('\n')) {
108
+ const at = line.indexOf(':');
109
+ if (at <= 0)
110
+ continue;
111
+ const doc = line.slice(0, at);
112
+ if (!found.has(doc) && found.size >= MAX_DOCS)
113
+ continue;
114
+ found.set(doc, (found.get(doc) ?? new Set()).add(line.slice(at + 1)));
115
+ }
116
+ return [...found].map(([doc, hits]) => ({ doc, names: [...hits].slice(0, 3) }));
117
+ }
118
+ /**
119
+ * `rigour dismiss <id>` on a reviewer finding: only where the team allows it (`review.reviewer.dismissals`), with a
120
+ * reason, recorded with who dismissed it. The item is found in this branch's latest verdict.
121
+ */
122
+ export async function dismissReviewerFinding(cwd, id, reason, allowed, exec = defaultExec) {
123
+ if (!allowed)
124
+ return { error: 'this team does not dismiss reviewer findings (review.reviewer.dismissals is off): fix the code, or improve the reviewer (its prompt, rules or the reviewers it runs) so the finding is not raised' };
125
+ if (reason.trim().length < 5)
126
+ return { error: 'say why it is not a bug, in a few words' };
127
+ const store = await VerdictStore.open(cwd, exec);
128
+ if (!store)
129
+ return { error: 'not a git repository' };
130
+ const branch = (await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
131
+ const state = store.branchState(branch);
132
+ const open = state ? store.readJson(store.openPath(state.verdict)) ?? [] : [];
133
+ const item = open.find(i => i.id === id);
134
+ if (!item)
135
+ return { error: `no open reviewer finding ${id} on ${branch}: run \`rigour review --reviewer\` and copy the id it shows` };
136
+ const who = (await exec('git', ['config', 'user.email'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim() || (await exec('git', ['config', 'user.name'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
137
+ if (!dismissReviewItem(cwd, item, reason, who))
138
+ return { error: `${id} is a human review point: answer it in the reply, it cannot be dismissed here` };
139
+ return { item };
140
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dismissedAs } from './context.js';
3
+ const dismissal = { id: 'abcdef0123', file: 'src/x.ts', line: 40, class: 'correctness', issue: 'cache key built from user id may collide', reason: 'ids are unique per tenant', at: '2026-10-06' };
4
+ const finding = (over) => ({ id: 'ffff000011', kind: 'finding', class: 'correctness', file: 'src/x.ts', line: 40, issue: 'cache key built from the user id may collide', consequence: 'two users share an entry', ...over });
5
+ describe('a dismissal', () => {
6
+ it('covers the same finding re-worded nearby', () => {
7
+ expect(dismissedAs(finding({}), [dismissal])).toBe(dismissal);
8
+ expect(dismissedAs(finding({ line: 42, issue: 'the cache key built from user id may collide' }), [dismissal])).toBe(dismissal);
9
+ expect(dismissedAs(finding({ id: 'abcdef0123', issue: 'anything at all' }), [dismissal])).toBe(dismissal); // its own id
10
+ });
11
+ it('never hides a different bug, even on the same line and in the same class', () => {
12
+ expect(dismissedAs(finding({ issue: 'cache entry never invalidated when the user changes' }), [dismissal])).toBeUndefined();
13
+ expect(dismissedAs(finding({ line: 42, issue: 'cache entry never invalidated when user id changes' }), [dismissal])).toBeUndefined();
14
+ expect(dismissedAs(finding({ line: 80 }), [dismissal])).toBeUndefined(); // far away
15
+ expect(dismissedAs(finding({ class: 'production-cost' }), [dismissal])).toBeUndefined();
16
+ expect(dismissedAs({ ...finding({}), kind: 'prior' }, [dismissal])).toBeUndefined(); // a human's point is never dismissed this way
17
+ });
18
+ });
@@ -1,8 +1,12 @@
1
1
  /** How the reviewer runs commands (git, gh, the agent CLIs); tests replace it. */
2
2
  import { execa } from 'execa';
3
3
  export const defaultExec = async (command, args, options) => {
4
- const result = await execa(command, args, { cwd: options.cwd, reject: false, timeout: options.timeoutMs, input: '', env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 64 * 1024 * 1024 });
5
- return { exitCode: result.exitCode ?? 1, stdout: String(result.stdout ?? ''), stderr: String(result.stderr ?? '') };
4
+ // stdin is /dev/null, not an empty pipe: an agent CLI that reads stdin sees its end at once, and a command that exits
5
+ // before an empty write lands (git rev-parse) cannot fail the run with EPIPE, as it did on Linux.
6
+ const result = await execa(command, args, { cwd: options.cwd, reject: false, timeout: options.timeoutMs, stdin: 'ignore', env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 64 * 1024 * 1024 });
7
+ const stderr = String(result.stderr ?? '');
8
+ // A command that could not start, timed out or was killed writes nothing itself: say which, never an empty failure.
9
+ return { exitCode: result.exitCode ?? 1, stdout: String(result.stdout ?? ''), stderr: stderr || (result.failed && 'shortMessage' in result ? String(result.shortMessage) : '') };
6
10
  };
7
11
  export const GH_TIMEOUT_MS = 60_000;
8
12
  /** The named GitHub account's token for `gh`, when the person keeps several; otherwise gh's own. */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import os from 'os';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { defaultExec } from './exec.js';
4
+ describe('running a command', () => {
5
+ it('passes the command\'s own output through', async () => {
6
+ const result = await defaultExec(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'], { cwd: os.tmpdir(), timeoutMs: 30_000 });
7
+ expect(result).toEqual({ exitCode: 3, stdout: 'out', stderr: 'err' });
8
+ });
9
+ it('gives the command an ended stdin, so one that reads it never waits and one that exits at once never fails', async () => {
10
+ const reader = await defaultExec(process.execPath, ['-e', 'let n = 0; process.stdin.on("data", d => n += d.length).on("end", () => process.stdout.write(`read ${n}`))'], { cwd: os.tmpdir(), timeoutMs: 30_000 });
11
+ expect(reader).toEqual({ exitCode: 0, stdout: 'read 0', stderr: '' });
12
+ const runs = await Promise.all(Array.from({ length: 50 }, () => defaultExec(process.execPath, ['-e', 'process.stdout.write("ok")'], { cwd: os.tmpdir(), timeoutMs: 30_000 })));
13
+ expect(runs.every(r => r.exitCode === 0 && r.stdout === 'ok')).toBe(true);
14
+ });
15
+ it('says why a command that never answered failed: not started, or timed out', async () => {
16
+ const missing = await defaultExec('rigour-no-such-command', [], { cwd: os.tmpdir(), timeoutMs: 30_000 });
17
+ expect(missing.exitCode).not.toBe(0);
18
+ expect(missing.stderr).toContain('rigour-no-such-command'); // ENOENT on Unix, "is not recognized" on Windows
19
+ const slow = await defaultExec(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { cwd: os.tmpdir(), timeoutMs: 200 });
20
+ expect(slow.exitCode).not.toBe(0);
21
+ expect(slow.stderr).toMatch(/timed out/i);
22
+ });
23
+ });
@@ -22,6 +22,8 @@ export declare function findPullRequest(gh: Gh, branch: string, head: string, na
22
22
  pr?: PullRequest;
23
23
  error?: string;
24
24
  }>;
25
+ /** Written by a person: not a bot, and not the pull request's author answering their own review. */
26
+ export declare function isHuman(item: any, author: string): boolean;
25
27
  /** Every review by a person and every inline comment by a person, hidden from `reviewsBefore` on. */
26
28
  export declare function humanReviews(gh: Gh, pr: PullRequest, reviewsBefore: string | undefined): Promise<{
27
29
  reviews?: HumanReviews;
@@ -47,6 +47,10 @@ async function viewPullRequest(gh, selector) {
47
47
  return { error: `gh returned something other than a pull request for ${selector}: ${result.stdout.slice(0, 120)}` };
48
48
  }
49
49
  }
50
+ /** Written by a person: not a bot, and not the pull request's author answering their own review. */
51
+ export function isHuman(item, author) {
52
+ return !!item?.user && item.user.type !== 'Bot' && !/\[bot\]$/.test(item.user.login) && item.user.login !== author;
53
+ }
50
54
  /** Every review by a person and every inline comment by a person, hidden from `reviewsBefore` on. */
51
55
  export async function humanReviews(gh, pr, reviewsBefore) {
52
56
  const reviews = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/reviews`, '--paginate']);
@@ -55,7 +59,7 @@ export async function humanReviews(gh, pr, reviewsBefore) {
55
59
  const comments = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/comments`, '--paginate']);
56
60
  if (comments.exitCode !== 0)
57
61
  return { error: `could not read the comments of pull request ${pr.number}: ${comments.stderr.trim().slice(0, 200)}` };
58
- const human = (x) => x?.user && x.user.type !== 'Bot' && !/\[bot\]$/.test(x.user.login) && x.user.login !== pr.author;
62
+ const human = (x) => isHuman(x, pr.author);
59
63
  const before = (at) => !reviewsBefore || (typeof at === 'string' && at < reviewsBefore);
60
64
  const rounds = parseJsonArrays(reviews.stdout).filter((r) => human(r) && String(r.body ?? '').trim() && before(r.submitted_at));
61
65
  const inline = parseJsonArrays(comments.stdout).filter((c) => human(c) && before(c.created_at));
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Two or three judges as a panel, run around the consensus rules (consensus.ts). The judges' own
3
+ * items are grouped into findings; a finding a strict majority raised is confirmed. Every other
4
+ * judge then cross-examines, in one batched call each, only the findings it did not raise: confirm
5
+ * or refute with file:line evidence; no evidence or no answer is unsure. The calls decide by
6
+ * majority; a disputed finding is shown and never blocks. A finding disputed before, on a file no
7
+ * commit has touched since, stays disputed without another call; past the item cap, disputed too.
8
+ *
9
+ * Every finding keeps what a later re-scoring needs (who raised it, every judge's call, the
10
+ * cross-examination answers, each judge's own item and the class), so a probabilistic truth model
11
+ * can be fitted to the ledger later without running anyone again.
12
+ */
13
+ import { type Call, type PanelStatus } from './consensus.js';
14
+ import type { OpenItem } from './verdict.js';
15
+ export interface PanelItem {
16
+ /** The item as its first raiser reported it. */
17
+ item: OpenItem;
18
+ /** The judges who raised it unprompted. */
19
+ judges: string[];
20
+ /** Every judge's call on it, raised or answered in cross-examination. */
21
+ calls: Record<string, Call>;
22
+ /** Every cross-examination answer about it, with the code each judge quoted. */
23
+ cross?: Array<{
24
+ by: string;
25
+ call: Call;
26
+ evidence?: string;
27
+ }>;
28
+ /** Each judge's own item when more than one wrote it: what a later re-scoring of the panel needs. */
29
+ members?: Array<{
30
+ judge: string;
31
+ item: OpenItem;
32
+ }>;
33
+ status: PanelStatus;
34
+ /** Why it is disputed without a cross-examination (over the cap, disputed before and untouched since). */
35
+ note?: string;
36
+ }
37
+ /** A judge's answer about another judge's items: confirm or refute, each with the code that shows it. */
38
+ export interface Answer {
39
+ id: string;
40
+ call: 'confirm' | 'refute' | 'unsure';
41
+ evidence?: string;
42
+ }
43
+ export interface PanelInput {
44
+ judges: string[];
45
+ items: OpenItem[][];
46
+ /** Items disputed in the previous verdict, and the files changed since it. */
47
+ previousDisputed: OpenItem[];
48
+ touched: Set<string>;
49
+ maxItems: number;
50
+ /** Asks `judge` about items other judges raised; resolves to its answers (a missing one counts as unsure). */
51
+ ask: (judge: string, items: OpenItem[]) => Promise<Answer[]>;
52
+ /** Why no more agent runs may start today (a daily cap), checked before each judge's cross-examination. */
53
+ capped?: () => string | undefined;
54
+ /** Whether an answer's evidence quotes real code: a `file:line` the checkout has. */
55
+ evidenced: (evidence: string) => boolean;
56
+ }
57
+ /** The panel's decision on every finding any judge raised. */
58
+ export declare function runPanel(input: PanelInput): Promise<PanelItem[]>;
59
+ /** The answers in a judge's cross-examination reply; nothing usable is no answers (every item then disputed). */
60
+ export declare function parseAnswers(text: string): Answer[];
61
+ /** The accounting with the panel's decision applied: the judges' own items leave, and only what it confirmed blocks. */
62
+ export declare function applyPanel<T extends {
63
+ open: OpenItem[];
64
+ }>(accounted: T, judgeItemIds: Set<string>, decided: PanelItem[]): T & {
65
+ disputed: OpenItem[];
66
+ dropped: OpenItem[];
67
+ };