@rigour-labs/core 6.9.0-rc.3 → 6.9.0-rc.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 (36) hide show
  1. package/dist/index.d.ts +3 -2
  2. package/dist/index.js +2 -2
  3. package/dist/outcomes/metrics.d.ts +88 -0
  4. package/dist/outcomes/metrics.js +56 -0
  5. package/dist/outcomes/run.d.ts +5 -0
  6. package/dist/outcomes/run.js +26 -6
  7. package/dist/review/reviewer/orchestrator.d.ts +51 -0
  8. package/dist/review/reviewer/orchestrator.js +96 -0
  9. package/dist/review/reviewer/settings.js +1 -1
  10. package/dist/review/reviewer/specialists/cleanup.v1.d.ts +9 -0
  11. package/dist/review/reviewer/specialists/cleanup.v1.js +9 -0
  12. package/dist/review/reviewer/specialists/correctness.v1.d.ts +9 -0
  13. package/dist/review/reviewer/specialists/correctness.v1.js +9 -0
  14. package/dist/review/reviewer/specialists/prior-points.v1.d.ts +9 -0
  15. package/dist/review/reviewer/specialists/prior-points.v1.js +9 -0
  16. package/dist/review/reviewer/specialists/production-cost.v1.d.ts +9 -0
  17. package/dist/review/reviewer/specialists/production-cost.v1.js +9 -0
  18. package/dist/review/reviewer/specialists/rules-and-goal.v1.d.ts +9 -0
  19. package/dist/review/reviewer/specialists/rules-and-goal.v1.js +9 -0
  20. package/dist/review/reviewer/store.d.ts +35 -0
  21. package/dist/review/reviewer/store.js +41 -0
  22. package/dist/review/reviewer/triage.d.ts +62 -0
  23. package/dist/review/reviewer/triage.js +147 -0
  24. package/dist/review/reviewer/usage.js +14 -0
  25. package/dist/review/reviewer/verdict.js +24 -7
  26. package/dist/review/reviewer.d.ts +31 -2
  27. package/dist/review/reviewer.js +187 -43
  28. package/dist/review-learning/lessons.d.ts +6 -0
  29. package/dist/review-learning/lessons.js +11 -0
  30. package/dist/settings.d.ts +1 -0
  31. package/dist/switches.d.ts +311 -0
  32. package/dist/switches.js +1 -0
  33. package/dist/templates/universal-config.js +1 -0
  34. package/dist/types/index.d.ts +11 -0
  35. package/dist/types/index.js +5 -0
  36. package/package.json +6 -6
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The orchestrator's router: which specialists a change needs, hunk by hunk, decided without a model, and the passes
3
+ * that takes. One combined pass is the plan at every size; a change over the judge's limit (orchestrator.ts passLimit)
4
+ * also gets a split by hunk, each part within the limit, which runs only when the savings ledger covers it. A change
5
+ * with nothing for a model to review (only lockfiles, generated files) gets no pass at all.
6
+ */
7
+ import type { Specialist } from './orchestrator.js';
8
+ export interface Hunk {
9
+ file: string;
10
+ /** The hunk as it appears in the diff, its file header included, so a slice is itself a diff. */
11
+ text: string;
12
+ added: string[];
13
+ removed: string[];
14
+ newFile: boolean;
15
+ /** Names the hunk defines on an added line (function, const, class, type). */
16
+ defines: Set<string>;
17
+ /** Every identifier on its added and removed lines. */
18
+ references: Set<string>;
19
+ }
20
+ /** The most parts a split runs: a change that needs more is one combined pass. */
21
+ export declare const MAX_PARTS = 3;
22
+ /** The diff's hunks, each with its file header. */
23
+ export declare function parseHunks(diff: string): Hunk[];
24
+ export interface TriageContext {
25
+ /** Human reviews on the pull request: the prior-points specialist is for them. */
26
+ humanReviews: number;
27
+ /** Rules and lessons the team's knowledge serves for this change. */
28
+ rulesAndLessons: number;
29
+ /** The goal step has items for a model to judge. */
30
+ goal: boolean;
31
+ }
32
+ /** Per specialist, the hunks it is for (by index); a specialist absent from the map is not needed. Prior points take the whole change. */
33
+ export declare function triage(hunks: Hunk[], context: TriageContext): Map<string, number[]>;
34
+ /** Whether no model reviews this file (SKIP). */
35
+ export declare function skipped(file: string): boolean;
36
+ export interface Pass {
37
+ specialists: string[];
38
+ /** The hunks triage picked for it. */
39
+ hunks: number[];
40
+ /** What it is given: its hunks and the hunks defining what they use. */
41
+ sliced: number[];
42
+ diff: string;
43
+ }
44
+ export interface Plan {
45
+ /** One pass with every picked specialist; undefined when triage picked nothing. */
46
+ combined?: Pass;
47
+ /** Present only when the combined pass is over `limit`: the picked hunks in parts, each within it where one hunk allows. */
48
+ split?: Pass[];
49
+ /** The parts a change over `limit` would need, when that is more than MAX_PARTS: no split, one combined pass. */
50
+ needsParts?: number;
51
+ }
52
+ /**
53
+ * The passes for what triage picked. A split is by hunk, in diff order: each part takes the next hunks while they stay
54
+ * within `limit`, and runs every specialist that picked any of them. A single hunk over the limit is a part of its own.
55
+ * A change that needs more than MAX_PARTS parts is not split.
56
+ */
57
+ export declare function planPasses(hunks: Hunk[], picked: Map<string, number[]>, order: readonly Specialist[], limit: number): Plan;
58
+ /** Characters of the reviewable diff (no lockfiles or generated files) and its changed lines: both modes are measured on this. */
59
+ export declare function reviewable(hunks: Hunk[]): {
60
+ chars: number;
61
+ lines: number;
62
+ };
@@ -0,0 +1,147 @@
1
+ /** Prose: its own words are for the rules and the goal, never a correctness pass. Matched by extension only: a `docs/` folder holds code too. */
2
+ const PROSE = /\.(md|mdx|txt|rst|adoc)$/i;
3
+ /**
4
+ * The only files no model reviews: lockfiles, snapshots, source maps, minified bundles and files a generator marks
5
+ * as its own (`__generated__/`, `.generated.`, protobuf output). Everything else gets a correctness pass, whatever
6
+ * its language: a missed skip costs a little, a wrong one leaves code unreviewed.
7
+ */
8
+ const SKIP = [
9
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|go\.sum|Gemfile\.lock|composer\.lock|bun\.lockb?)$/,
10
+ /\.snap$/, /\.(js|css|d\.ts)\.map$/, /\.min\.(js|css)$/,
11
+ /(^|\/)__generated__\//, /\.generated\.[A-Za-z0-9]+$/, /\.pb\.go$/, /_pb2(_grpc)?\.pyi?$/, /_pb\.(js|ts|d\.ts)$/,
12
+ ];
13
+ /** The most parts a split runs: a change that needs more is one combined pass. */
14
+ export const MAX_PARTS = 3;
15
+ const MIGRATION = /(^|\/)(migrations?|db\/migrate)\/|\.sql$|(^|\/)schema\.prisma$/i;
16
+ /**
17
+ * Lines that read data, per language. Each names a query API, not a word any code uses: `Array.from`, `map.get`,
18
+ * `items.filter` and a `limit` variable are not reads.
19
+ */
20
+ const READS = [
21
+ // SQL, in a .sql file or a string
22
+ /\bselect\s[\s\S]{0,80}?\bfrom\s+[A-Za-z_"`[]|\binsert\s+into\b|\bupdate\s+[A-Za-z_"`.]+\s+set\b|\bdelete\s+from\b|\bcreate\s+(unique\s+)?index\b|\balter\s+table\b|\bcreate\s+table\b|\blimit\s+\d+|\boffset\s+\d+/i,
23
+ // TS/JS: ORMs, query builders, Supabase, fetch
24
+ /\.(query|queryRaw|\$queryRaw|\$executeRaw|findMany|findFirst|findUnique|findOne|findAll|aggregate|groupBy|rpc)\s*\(|\.from\(\s*['"`]|\bfetch\s*\(|\.(range|limit|offset)\s*\(\s*\d/,
25
+ // Python: DB-API, SQLAlchemy, Django
26
+ /\.(execute|executemany|fetchall|fetchone|fetchmany)\s*\(|\bsession\.(query|execute|scalars)\s*\(|\.objects\.(filter|all|get|exclude|raw)\s*\(/,
27
+ // Go: database/sql, sqlx, GORM on a db/tx/conn value
28
+ /\.(Query|QueryRow|QueryContext|QueryRowContext|Exec|ExecContext)\s*\(|\b(db|tx|conn)\.(Get|Select|Find|First|Where)\s*\(|\brows\.Next\s*\(/,
29
+ ];
30
+ /** A loop, and an await within the next few lines of it: one read per turn. */
31
+ const LOOP = /\b(for|while)\b\s*[(\w]|\.(forEach|map|flatMap|reduce)\s*\(\s*async\b|\basync\s+for\b/;
32
+ const AWAIT = /\bawait\b/;
33
+ const LOOP_REACH = 3;
34
+ const DEFINES = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|var|class|interface|type|enum|def|func)\s+(?:\([^)]*\)\s*)?([A-Za-z_$][\w$]*)/;
35
+ const DECLARES = /^\s*(export\s|(async\s+)?function\s|def\s|func\s|class\s)/;
36
+ const COMMENT = /^\s*(\/\/|#|\/\*|\*|<!--)/;
37
+ const IDENTIFIER = /[A-Za-z_$][\w$]{2,}/g;
38
+ /** The diff's hunks, each with its file header. */
39
+ export function parseHunks(diff) {
40
+ const hunks = [];
41
+ for (const block of diff.split(/^(?=diff --git )/m)) {
42
+ const file = /^diff --git a\/.+? b\/(.+)$/m.exec(block)?.[1];
43
+ if (!file)
44
+ continue;
45
+ const headerEnd = block.search(/^@@/m);
46
+ const header = headerEnd >= 0 ? block.slice(0, headerEnd) : block;
47
+ const newFile = /^new file mode|^--- \/dev\/null$/m.test(header);
48
+ const bodies = headerEnd >= 0 ? block.slice(headerEnd).split(/^(?=@@)/m) : [];
49
+ for (const body of bodies) {
50
+ const lines = body.split('\n');
51
+ const added = lines.filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1));
52
+ const removed = lines.filter(l => l.startsWith('-') && !l.startsWith('---')).map(l => l.slice(1));
53
+ const defines = new Set(added.map(l => DEFINES.exec(l)?.[1]).filter((n) => !!n));
54
+ const references = new Set([...added, ...removed].flatMap(l => l.match(IDENTIFIER) ?? []));
55
+ hunks.push({ file, text: `${header}${body}`, added, removed, newFile, defines, references });
56
+ }
57
+ }
58
+ return hunks;
59
+ }
60
+ /** Per specialist, the hunks it is for (by index); a specialist absent from the map is not needed. Prior points take the whole change. */
61
+ export function triage(hunks, context) {
62
+ const picked = new Map();
63
+ const pick = (id, index) => picked.set(id, [...(picked.get(id) ?? []), index]);
64
+ hunks.forEach((hunk, i) => {
65
+ if (skipped(hunk.file))
66
+ return;
67
+ const prose = PROSE.test(hunk.file);
68
+ const lines = [...hunk.added, ...hunk.removed];
69
+ if (!prose)
70
+ pick('correctness', i);
71
+ if (!prose && (MIGRATION.test(hunk.file) || READS.some(pattern => lines.some(line => pattern.test(line))) || awaitsInLoop(hunk.added)))
72
+ pick('production-cost', i);
73
+ if (!prose && (hunk.removed.length > 0 || hunk.newFile || hunk.added.some(l => DECLARES.test(l))))
74
+ pick('cleanup', i);
75
+ if (prose || hunk.added.some(l => COMMENT.test(l)) || context.rulesAndLessons > 0 || context.goal)
76
+ pick('rules-and-goal', i);
77
+ });
78
+ if (context.humanReviews > 0)
79
+ picked.set('prior-points', hunks.map((_, i) => i).filter(i => !skipped(hunks[i].file)));
80
+ return picked;
81
+ }
82
+ /** Whether no model reviews this file (SKIP). */
83
+ export function skipped(file) {
84
+ return SKIP.some(pattern => pattern.test(file));
85
+ }
86
+ function awaitsInLoop(lines) {
87
+ return lines.some((line, i) => LOOP.test(line) && lines.slice(i, i + LOOP_REACH + 1).some(l => AWAIT.test(l)));
88
+ }
89
+ /**
90
+ * A pass's part of the diff: its hunks, and the hunk defining each name they use, in diff order. Only a name exactly one
91
+ * hunk defines is followed (a local `result` defined in ten files is no one definition), and a definition is added only
92
+ * while the part stays within `limit`.
93
+ */
94
+ function sliceOf(hunks, definedIn, indices, limit) {
95
+ const chosen = new Set(indices);
96
+ let size = indices.reduce((sum, i) => sum + hunks[i].text.length, 0);
97
+ const referenced = new Set(indices.flatMap(i => [...hunks[i].references]));
98
+ for (const name of referenced) {
99
+ const at = definedIn.get(name);
100
+ if (at?.length !== 1 || chosen.has(at[0]) || size + hunks[at[0]].text.length > limit)
101
+ continue;
102
+ chosen.add(at[0]);
103
+ size += hunks[at[0]].text.length;
104
+ }
105
+ return [...chosen].sort((a, b) => a - b);
106
+ }
107
+ /**
108
+ * The passes for what triage picked. A split is by hunk, in diff order: each part takes the next hunks while they stay
109
+ * within `limit`, and runs every specialist that picked any of them. A single hunk over the limit is a part of its own.
110
+ * A change that needs more than MAX_PARTS parts is not split.
111
+ */
112
+ export function planPasses(hunks, picked, order, limit) {
113
+ const ids = order.map(s => s.id).filter(id => picked.has(id));
114
+ if (ids.length === 0)
115
+ return {};
116
+ const definedIn = new Map();
117
+ hunks.forEach((hunk, i) => hunk.defines.forEach(name => definedIn.set(name, [...(definedIn.get(name) ?? []), i])));
118
+ const pickedBy = new Map(ids.map(id => [id, new Set(picked.get(id))]));
119
+ const pass = (indices) => {
120
+ const sliced = sliceOf(hunks, definedIn, indices, limit);
121
+ return { specialists: ids.filter(id => indices.some(i => pickedBy.get(id).has(i))), hunks: indices, sliced, diff: sliced.map(i => hunks[i].text).join('') };
122
+ };
123
+ const union = [...new Set(ids.flatMap(id => picked.get(id)))].sort((a, b) => a - b);
124
+ const combined = pass(union);
125
+ if (combined.diff.length <= limit || union.length === 1)
126
+ return { combined };
127
+ // By the hunks' own size: definitions join a part only within the limit (sliceOf), so they never push it over.
128
+ const parts = [[]];
129
+ let size = 0;
130
+ for (const index of union) {
131
+ const length = hunks[index].text.length;
132
+ if (parts.at(-1).length && size + length > limit) {
133
+ parts.push([]);
134
+ size = 0;
135
+ }
136
+ parts.at(-1).push(index);
137
+ size += length;
138
+ }
139
+ if (parts.length > MAX_PARTS)
140
+ return { combined, needsParts: parts.length };
141
+ return parts.length > 1 ? { combined, split: parts.map(pass) } : { combined };
142
+ }
143
+ /** Characters of the reviewable diff (no lockfiles or generated files) and its changed lines: both modes are measured on this. */
144
+ export function reviewable(hunks) {
145
+ const kept = hunks.filter(h => !skipped(h.file));
146
+ return { chars: kept.reduce((sum, h) => sum + h.text.length, 0), lines: kept.reduce((sum, h) => sum + h.added.length + h.removed.length, 0) };
147
+ }
@@ -24,5 +24,19 @@ export function reviewerUsage(result, trigger) {
24
24
  dismissed: result.dismissed.length,
25
25
  runs: result.runs,
26
26
  cost_bucket: costBucket(result.costUsd),
27
+ ...orchestrated(mode?.specialists),
28
+ };
29
+ }
30
+ /** With the orchestrator: how many parts triage picked, how many passes ran, whether it split, fell back or had nothing to review, and how many passes read beyond their slice. */
31
+ function orchestrated(specialists) {
32
+ if (!specialists)
33
+ return {};
34
+ return {
35
+ parts: specialists.selected.length,
36
+ passes: specialists.passes.length,
37
+ split: specialists.passes.length > 1,
38
+ fallback: !!specialists.fallback,
39
+ nothing_to_review: !!specialists.none,
40
+ beyond_slice: specialists.passes.filter(p => p.readBeyondSlice === true).length,
27
41
  };
28
42
  }
@@ -243,6 +243,17 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
243
243
  const notes = [];
244
244
  const advisory = [];
245
245
  const seen = new Set();
246
+ // Every item kept, by id: the same item from a second judge or specialist names it too instead of vanishing.
247
+ const kept = new Map();
248
+ const again = (item) => {
249
+ const first = kept.get(item.id);
250
+ if (first)
251
+ first.reviewer = bothReviewers(first.reviewer, item.reviewer);
252
+ };
253
+ const keep = (list, item) => {
254
+ list.push(item);
255
+ kept.set(item.id, item);
256
+ };
246
257
  // The reviewer's own label wins over the judge's reading of it: a judge that calls a blocker a should-fix would demote it silently.
247
258
  const read = verdict.prior_points.map(p => labelled(p, prior.labels ?? []));
248
259
  const points = read.map(r => r.point);
@@ -250,29 +261,29 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
250
261
  // A should-fix is shown only when the judge could show it: a quote Rigour finds. One that cannot be checked is not a claim worth a person's time.
251
262
  const advise = (item) => {
252
263
  if (seen.has(item.id))
253
- return;
264
+ return void again(item);
254
265
  seen.add(item.id);
255
- (!!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote) ? advisory : unverified).push(item);
266
+ keep(!!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote) ? advisory : unverified, item);
256
267
  };
257
268
  // A prior point is the human's and needs no file. A finding blocks only when the code it quotes is at the line it
258
269
  // names: any model's claim is checked, never trusted. What the working steps turned up is a note: the reasoning,
259
270
  // shown, and a block only when the judge also makes it a finding it can quote.
260
271
  const add = (item) => {
261
272
  if (seen.has(item.id))
262
- return;
273
+ return void again(item);
263
274
  seen.add(item.id);
264
275
  if (WORKING_NOTES.has(item.kind))
265
- return void notes.push(item);
276
+ return void keep(notes, item);
266
277
  const placed = !!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote);
267
278
  if (!placed)
268
- return void unverified.push(item);
279
+ return void keep(unverified, item);
269
280
  // A block is about this change. A finding or rule break in a touched file but on lines the change did not touch is
270
281
  // what the code already had: shown as a note, never a block on this change. A human's point is about the change by
271
282
  // definition. Without the diff (a judge's own items for a panel, a test) nothing is known and nothing is moved.
272
283
  if (item.kind !== 'prior' && prior.changed && (item.line === undefined || !nearChanged(prior.changed, item.file, item.line))) {
273
- return void notes.push({ ...item, evidence: `${item.evidence ? `${item.evidence}; ` : ''}${item.line === undefined ? 'names no line' : 'on a line this change did not touch'}: what the code already had, never a block on this change` });
284
+ return void keep(notes, { ...item, evidence: `${item.evidence ? `${item.evidence}; ` : ''}${item.line === undefined ? 'names no line' : 'on a line this change did not touch'}: what the code already had, never a block on this change` });
274
285
  }
275
- open.push(item);
286
+ keep(open, item);
276
287
  };
277
288
  const answerInReply = [];
278
289
  for (const p of points) {
@@ -441,11 +452,17 @@ function onePerRootCause(items) {
441
452
  kept.push(item);
442
453
  continue;
443
454
  }
455
+ same.reviewer = bothReviewers(same.reviewer, item.reviewer);
444
456
  if (item.file)
445
457
  (same.locations ??= []).push({ file: item.file, ...(item.line ? { line: item.line } : {}) });
446
458
  }
447
459
  return kept;
448
460
  }
461
+ /** Who found an item, each once: `claude:correctness+claude:cleanup`, as the panel tags `claude+codex`. */
462
+ function bothReviewers(a, b) {
463
+ const names = [...new Set([...(a ?? '').split('+'), ...(b ?? '').split('+')].filter(Boolean))];
464
+ return names.length ? names.join('+') : undefined;
465
+ }
449
466
  export function itemLine(item) {
450
467
  const where = item.file ? ` ${item.file}${item.line ? `:${item.line}` : ''}` : '';
451
468
  const by = item.reviewer ? ` (${item.reviewer})` : '';
@@ -35,6 +35,8 @@ export interface ReviewerOptions {
35
35
  branch?: string;
36
36
  /** This run's choice for the goal check (`--goal` / `--no-goal`), the nearest layer of switches.ts. */
37
37
  goal?: boolean;
38
+ /** This run's choice for the orchestrator (`--orchestrator` / `--no-orchestrator`), the nearest layer of switches.ts. */
39
+ orchestrator?: boolean;
38
40
  }
39
41
  export interface ReviewerResult {
40
42
  outcome: ReviewerOutcome;
@@ -64,6 +66,8 @@ export interface ReviewerResult {
64
66
  scope?: 'full' | 'delta';
65
67
  why?: string;
66
68
  costUsd?: number;
69
+ /** What every run of this fresh review reported costing, failed runs included: the number its cost row and its thread event carry. */
70
+ spentUsd?: number;
67
71
  /** The repository's own rules the judge answered, and how. */
68
72
  rules?: {
69
73
  checked: number;
@@ -92,9 +96,34 @@ export interface ReviewerResult {
92
96
  prTitle?: string;
93
97
  }
94
98
  export interface ModeRecord {
95
- asked: 'single' | 'cross' | 'full' | 'panel';
99
+ asked: 'single' | 'cross' | 'full' | 'panel' | 'orchestrator';
96
100
  /** `none` when the review ended before any judge ran (unavailable or skipped); `degraded` or the reason says why. */
97
- ran: 'single' | 'cross' | 'full' | 'panel' | 'none';
101
+ ran: 'single' | 'cross' | 'full' | 'panel' | 'orchestrator' | 'none';
102
+ /**
103
+ * With the orchestrator: the specialists triage picked, the passes that returned and those that did not (by label),
104
+ * each pass (its parts, its slice, whether it read beyond the slice: null without a trace), why the plan is what it
105
+ * is when the change was over the judge's limit or the caps, why it fell back to one judge if it did, and `none`
106
+ * when there was nothing for a model to review.
107
+ */
108
+ specialists?: {
109
+ selected: string[];
110
+ returned: string[];
111
+ missing: string[];
112
+ passes: Array<{
113
+ specialists: string[];
114
+ hunks: number;
115
+ chars: number;
116
+ readBeyondSlice: boolean | null;
117
+ }>;
118
+ /** The most diff one pass could be given, and the judge it came from (orchestrator.ts passLimit). */
119
+ limit: {
120
+ judge: ReviewerName;
121
+ chars: number;
122
+ };
123
+ plan?: string;
124
+ fallback?: string;
125
+ none?: string;
126
+ };
98
127
  source: Source;
99
128
  /** Why fewer judges ran than were asked for. */
100
129
  degraded?: string;