@verevoir/review 0.2.0 → 0.3.1

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 (57) hide show
  1. package/README.md +132 -2
  2. package/dist/cli/bin.d.ts +2 -0
  3. package/dist/cli/bin.js +46 -0
  4. package/dist/cli/footer.d.ts +11 -0
  5. package/dist/cli/footer.js +68 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +518 -0
  8. package/dist/context-assembler.d.ts +9 -0
  9. package/dist/context-assembler.js +118 -0
  10. package/dist/context-files.d.ts +33 -0
  11. package/dist/context-files.js +174 -0
  12. package/dist/context-types.d.ts +28 -0
  13. package/dist/context-types.js +5 -0
  14. package/dist/dedup.d.ts +46 -0
  15. package/dist/dedup.js +169 -0
  16. package/dist/diff-hunks.d.ts +17 -0
  17. package/dist/diff-hunks.js +73 -0
  18. package/dist/fs-source-access.d.ts +7 -0
  19. package/dist/fs-source-access.js +39 -0
  20. package/dist/parseReply.js +58 -7
  21. package/dist/practice-lenses.d.ts +78 -0
  22. package/dist/practice-lenses.js +230 -0
  23. package/dist/prompt.d.ts +26 -0
  24. package/dist/prompt.js +18 -0
  25. package/dist/references.d.ts +44 -0
  26. package/dist/references.js +312 -0
  27. package/dist/report.d.ts +17 -1
  28. package/dist/report.js +64 -7
  29. package/dist/reviewer-anthropic.js +7 -1
  30. package/dist/run-review.d.ts +24 -0
  31. package/dist/run-review.js +44 -2
  32. package/dist/source-access.d.ts +8 -0
  33. package/dist/source-access.js +1 -0
  34. package/dist/subject-context-render.d.ts +8 -0
  35. package/dist/subject-context-render.js +29 -0
  36. package/dist/tier.d.ts +35 -0
  37. package/dist/tier.js +172 -0
  38. package/dist/verdict.d.ts +3 -0
  39. package/dist/warm-first.d.ts +2 -0
  40. package/dist/warm-first.js +21 -0
  41. package/lenses.default.json +30 -0
  42. package/package.json +5 -1
  43. package/src/cli/bin.ts +61 -0
  44. package/src/cli/footer.ts +88 -0
  45. package/src/cli/review.ts +656 -0
  46. package/src/context-files.ts +194 -0
  47. package/src/dedup.ts +207 -0
  48. package/src/parseReply.ts +58 -7
  49. package/src/practice-lenses.ts +286 -0
  50. package/src/prompt.ts +47 -0
  51. package/src/references.ts +381 -0
  52. package/src/report.ts +81 -7
  53. package/src/reviewer-anthropic.ts +7 -1
  54. package/src/run-review.ts +70 -7
  55. package/src/tier.ts +207 -0
  56. package/src/verdict.ts +3 -0
  57. package/src/warm-first.ts +27 -0
@@ -0,0 +1,381 @@
1
+ // Deterministic resolution of references in a diff's ADDED lines, so a lens can see whether
2
+ // an `ADR 009` citation points at the right ADR, or a cited file path exists, instead of
3
+ // guessing. No model call. Runs only when there is a context root.
4
+ //
5
+ // Scanned: ADR references (`ADR 009`, `ADR-009`, `ADR 9`) and repo-relative file paths with
6
+ // common extensions, optionally with `:line`. Only lines the change ADDS are scanned.
7
+ //
8
+ // SAFETY: same rules as context-files.ts. A cited path with a `..` segment, an absolute path
9
+ // or a NUL is refused without being touched; a path or ADR file that resolves (through a
10
+ // symlink) outside the context root is refused. The ADR search lists directories to a depth of
11
+ // 4 and skips node_modules, .git and dist.
12
+
13
+ import { diffPaths, unsafePathReason } from './context-files.js';
14
+ import type { ContextFs } from './context-files.js';
15
+
16
+ /** At most this many distinct references are resolved; the rest are counted, not resolved. */
17
+ export const MAX_REFERENCES = 40;
18
+
19
+ /** Directory levels below the root that the ADR search lists. */
20
+ export const ADR_SEARCH_DEPTH = 4;
21
+
22
+ /** The basename search visits at most this many entries before it stops and says so. */
23
+ export const MAX_SEARCH_FILES = 5000;
24
+
25
+ /** Directory levels below the root that the basename search descends. */
26
+ export const BASENAME_SEARCH_DEPTH = 8;
27
+
28
+ export interface ReferenceOptions {
29
+ /** Overrides MAX_SEARCH_FILES (tests). */
30
+ readonly maxSearchFiles?: number;
31
+ }
32
+
33
+ const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist']);
34
+ const ADR_DIRS = new Set(['adrs', 'adr']);
35
+
36
+ export interface DirEntry {
37
+ readonly name: string;
38
+ readonly isDir: boolean;
39
+ }
40
+
41
+ export interface ReferenceFs extends ContextFs {
42
+ /** List a directory's entries. Absent where the host cannot list directories. */
43
+ readonly listDir?: (path: string) => Promise<DirEntry[]>;
44
+ }
45
+
46
+ export interface AddedLine {
47
+ readonly file: string;
48
+ readonly line: number;
49
+ readonly text: string;
50
+ }
51
+
52
+ /** Lines the diff adds, with the file and new-side line number of each. */
53
+ export function addedLines(diff: string): AddedLine[] {
54
+ const out: AddedLine[] = [];
55
+ let file = '';
56
+ let inHunk = false;
57
+ let next = 0;
58
+ for (const line of diff.split(/\r?\n/)) {
59
+ if (line.startsWith('diff --git ')) {
60
+ inHunk = false;
61
+ continue;
62
+ }
63
+ if (!inHunk && line.startsWith('+++ ')) {
64
+ const raw = line.slice(4).split('\t')[0]?.trim() ?? '';
65
+ file = raw === '/dev/null' ? '' : raw.replace(/^b\//, '');
66
+ continue;
67
+ }
68
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
69
+ if (hunk !== null) {
70
+ inHunk = true;
71
+ next = Number(hunk[1]);
72
+ continue;
73
+ }
74
+ if (!inHunk) continue;
75
+ if (line.startsWith('+')) {
76
+ out.push({ file, line: next, text: line.slice(1) });
77
+ next++;
78
+ } else if (line.startsWith(' ')) {
79
+ next++;
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export interface Reference {
86
+ readonly kind: 'adr' | 'path';
87
+ /** ADR number (adr) or the cited path (path). */
88
+ readonly target: string;
89
+ /** The text as written, e.g. `ADR 9`. */
90
+ readonly display: string;
91
+ /** Where it was cited, `file:line`. */
92
+ readonly where: string;
93
+ }
94
+
95
+ const ADR_RE = /\bADR[ -]?0*(\d{1,4})\b/gi;
96
+ const PATH_RE =
97
+ /(?<![\w./-])((?:[\w.-]+\/)*[\w.-]+\.(?:md|ts|tsx|js|mjs|cjs|json|yml|yaml|sh))(?::(\d+))?(?![\w-])/g;
98
+
99
+ /** Distinct references in the added lines, in order of first appearance. */
100
+ export function findReferences(diff: string): Reference[] {
101
+ const seen = new Set<string>();
102
+ const out: Reference[] = [];
103
+ for (const added of addedLines(diff)) {
104
+ const where = `${added.file === '' ? '(unknown file)' : added.file}:${String(added.line)}`;
105
+ for (const m of added.text.matchAll(ADR_RE)) {
106
+ const target = String(Number(m[1]));
107
+ const key = `adr:${target}`;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ out.push({ kind: 'adr', target, display: m[0], where });
111
+ }
112
+ for (const m of added.text.matchAll(PATH_RE)) {
113
+ const target = m[1] ?? '';
114
+ const key = `path:${target}`;
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ out.push({ kind: 'path', target, display: m[0], where });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function isAbsent(cause: unknown): boolean {
124
+ const code = (cause as { code?: unknown }).code;
125
+ return code === 'ENOENT' || code === 'ENOTDIR';
126
+ }
127
+
128
+ function describe(cause: unknown): string {
129
+ return cause instanceof Error ? cause.message : String(cause);
130
+ }
131
+
132
+ interface Root {
133
+ readonly real: string;
134
+ readonly prefix: string;
135
+ }
136
+
137
+ function inside(root: Root, real: string): boolean {
138
+ return real === root.real || real.startsWith(root.prefix);
139
+ }
140
+
141
+ /** Relative paths of every file directly inside an `adrs` / `adr` directory, depth-limited. */
142
+ async function adrFiles(root: Root, fs: ReferenceFs): Promise<string[] | undefined> {
143
+ const list = fs.listDir;
144
+ if (list === undefined) return undefined;
145
+ const found: string[] = [];
146
+ const queue: { rel: string; depth: number }[] = [{ rel: '', depth: 0 }];
147
+ while (queue.length > 0) {
148
+ const { rel, depth } = queue.shift() ?? { rel: '', depth: 0 };
149
+ let entries: DirEntry[];
150
+ try {
151
+ entries = await list(rel === '' ? root.real : `${root.prefix}${rel}`);
152
+ } catch {
153
+ continue;
154
+ }
155
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
156
+ for (const entry of entries) {
157
+ const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
158
+ if (entry.isDir) {
159
+ if (SKIPPED_DIRS.has(entry.name)) continue;
160
+ if (ADR_DIRS.has(entry.name.toLowerCase())) {
161
+ try {
162
+ for (const inner of await list(`${root.prefix}${childRel}`)) {
163
+ if (!inner.isDir) found.push(`${childRel}/${inner.name}`);
164
+ }
165
+ } catch {
166
+ // an unreadable ADR directory simply contributes nothing
167
+ }
168
+ }
169
+ if (depth + 1 < ADR_SEARCH_DEPTH) queue.push({ rel: childRel, depth: depth + 1 });
170
+ }
171
+ }
172
+ }
173
+ return found;
174
+ }
175
+
176
+ function firstHeading(text: string): string | undefined {
177
+ const match = /^# (.+?)[ \t]*$/m.exec(text);
178
+ return match?.[1]?.trim();
179
+ }
180
+
181
+ async function resolveAdr(
182
+ ref: Reference,
183
+ root: Root,
184
+ files: string[] | undefined,
185
+ fs: ReferenceFs,
186
+ ): Promise<string> {
187
+ if (files === undefined) return 'could not be searched (this host cannot list directories)';
188
+ const pattern = new RegExp(`^0*${ref.target}-`);
189
+ const hits = files.filter((rel) => pattern.test(rel.slice(rel.lastIndexOf('/') + 1)));
190
+ if (hits.length === 0) return 'not found in this repository';
191
+ const parts: string[] = [];
192
+ for (const rel of hits) {
193
+ try {
194
+ const real = await fs.realpath(`${root.prefix}${rel}`);
195
+ if (!inside(root, real)) {
196
+ parts.push(`${rel} (refused: resolves outside the context root)`);
197
+ continue;
198
+ }
199
+ const title = firstHeading(await fs.readFile(real));
200
+ parts.push(title === undefined ? `${rel} (no title)` : `${rel}: '${title}'`);
201
+ } catch (cause) {
202
+ parts.push(`${rel} (could not be read: ${describe(cause)})`);
203
+ }
204
+ }
205
+ return parts.join('; ');
206
+ }
207
+
208
+ interface BasenameSearch {
209
+ readonly matches: string[];
210
+ readonly capped: boolean;
211
+ readonly searchable: boolean;
212
+ }
213
+
214
+ /** Breadth-first search for files named `name`, bounded by depth and entries visited. */
215
+ async function findByBasename(
216
+ root: Root,
217
+ fs: ReferenceFs,
218
+ name: string,
219
+ maxVisited: number,
220
+ ): Promise<BasenameSearch> {
221
+ const list = fs.listDir;
222
+ if (list === undefined) return { matches: [], capped: false, searchable: false };
223
+ const matches: string[] = [];
224
+ const queue: { rel: string; depth: number }[] = [{ rel: '', depth: 0 }];
225
+ let visited = 0;
226
+ while (queue.length > 0) {
227
+ const { rel, depth } = queue.shift() ?? { rel: '', depth: 0 };
228
+ let entries: DirEntry[];
229
+ try {
230
+ entries = await list(rel === '' ? root.real : `${root.prefix}${rel}`);
231
+ } catch {
232
+ continue;
233
+ }
234
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
235
+ for (const entry of entries) {
236
+ visited++;
237
+ if (visited > maxVisited) return { matches, capped: true, searchable: true };
238
+ const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
239
+ if (entry.isDir) {
240
+ if (SKIPPED_DIRS.has(entry.name)) continue;
241
+ if (depth + 1 < BASENAME_SEARCH_DEPTH) queue.push({ rel: childRel, depth: depth + 1 });
242
+ } else if (entry.name === name) {
243
+ matches.push(childRel);
244
+ }
245
+ }
246
+ }
247
+ return { matches, capped: false, searchable: true };
248
+ }
249
+
250
+ function basenameOf(path: string): string {
251
+ return path.slice(path.lastIndexOf('/') + 1);
252
+ }
253
+
254
+ async function isRootDir(root: Root, fs: ReferenceFs, name: string): Promise<boolean> {
255
+ try {
256
+ const real = await fs.realpath(`${root.prefix}${name}`);
257
+ return inside(root, real);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
264
+
265
+ async function resolvePath(
266
+ ref: Reference,
267
+ root: Root,
268
+ fs: ReferenceFs,
269
+ deleted: readonly string[],
270
+ maxVisited: number,
271
+ ): Promise<string> {
272
+ const unsafe = unsafePathReason(ref.target);
273
+ if (unsafe !== undefined) return `refused (${unsafe})`;
274
+ const segments = ref.target.split('/');
275
+ const bare = segments.length === 1;
276
+
277
+ // A real repo-relative path always wins.
278
+ try {
279
+ const real = await fs.realpath(`${root.prefix}${ref.target}`);
280
+ if (!inside(root, real)) return 'refused (resolves outside the context root)';
281
+ const stat = await fs.statFile(real);
282
+ return stat.isFile ? 'exists' : 'exists but is not a regular file';
283
+ } catch (cause) {
284
+ if (!isAbsent(cause)) return `could not be checked (${describe(cause)})`;
285
+ }
286
+
287
+ // An exact repo-relative path the diff deletes.
288
+ if (!bare && deleted.includes(ref.target)) return 'deleted by this change';
289
+
290
+ if (bare) {
291
+ // Surviving files win; a deleted copy only matters when none survives.
292
+ const deletedCopies = deleted.filter((d) => basenameOf(d) === ref.target);
293
+ const found = await findByBasename(root, fs, ref.target, maxVisited);
294
+ if (!found.searchable) {
295
+ return deletedCopies.length > 0
296
+ ? `deleted by this change (${deletedCopies.join(', ')})`
297
+ : 'not found at the context root (cannot search elsewhere: this host cannot list ' +
298
+ 'directories)';
299
+ }
300
+ const capNote = found.capped ? ' (search capped)' : '';
301
+ const deletedNote =
302
+ deletedCopies.length > 0
303
+ ? `; a copy at ${deletedCopies.join(', ')} was deleted by this change`
304
+ : '';
305
+ if (found.matches.length === 1) return `${found.matches[0] ?? ''}${capNote}${deletedNote}`;
306
+ if (found.matches.length > 1) {
307
+ return (
308
+ `ambiguous: ${String(found.matches.length)} matches, e.g. ` +
309
+ `${found.matches.slice(0, 2).join(', ')}${capNote}${deletedNote}`
310
+ );
311
+ }
312
+ if (deletedCopies.length > 0) return `deleted by this change (${deletedCopies.join(', ')})`;
313
+ return found.capped
314
+ ? 'not found in the part of this repository searched (search capped)'
315
+ : 'not found anywhere in this repository';
316
+ }
317
+
318
+ // owner/repo/... where neither first segment is a real directory here: another repository.
319
+ const [owner = '', repo = ''] = segments;
320
+ if (
321
+ segments.length >= 3 &&
322
+ OWNER_RE.test(owner) &&
323
+ !(await isRootDir(root, fs, owner)) &&
324
+ !(await isRootDir(root, fs, `${owner}/${repo}`))
325
+ ) {
326
+ return 'external (another repository), not checked';
327
+ }
328
+ return 'not found in this repository';
329
+ }
330
+
331
+ /**
332
+ * The body of the "References in added lines" section for `diff`, resolved against `rootPath`.
333
+ * Throws only when the root itself cannot be resolved.
334
+ */
335
+ export async function buildReferences(
336
+ diff: string,
337
+ rootPath: string,
338
+ fs: ReferenceFs,
339
+ options: ReferenceOptions = {},
340
+ ): Promise<string> {
341
+ const maxVisited = options.maxSearchFiles ?? MAX_SEARCH_FILES;
342
+ const deleted = diffPaths(diff)
343
+ .filter((p) => p.deleted && p.path !== '')
344
+ .map((p) => p.path);
345
+ let real: string;
346
+ try {
347
+ real = await fs.realpath(rootPath);
348
+ } catch (cause) {
349
+ throw new Error(`--context-root ${rootPath} could not be resolved: ${describe(cause)}`);
350
+ }
351
+ const root: Root = { real, prefix: real.endsWith('/') ? real : `${real}/` };
352
+
353
+ const all = findReferences(diff);
354
+ if (all.length === 0) return 'No ADR or file references were found in the added lines.';
355
+ const refs = all.slice(0, MAX_REFERENCES);
356
+
357
+ let adrs: string[] | undefined;
358
+ let adrsSearched = false;
359
+ const lines: string[] = [];
360
+ for (const ref of refs) {
361
+ let result: string;
362
+ if (ref.kind === 'adr') {
363
+ if (!adrsSearched) {
364
+ adrs = await adrFiles(root, fs);
365
+ adrsSearched = true;
366
+ }
367
+ result = await resolveAdr(ref, root, adrs, fs);
368
+ } else {
369
+ result = await resolvePath(ref, root, fs, deleted, maxVisited);
370
+ }
371
+ const shown = ref.kind === 'adr' ? ref.display : `\`${ref.display}\``;
372
+ lines.push(`- ${shown} (${ref.where}) → ${result}`);
373
+ }
374
+ if (all.length > refs.length) {
375
+ lines.push(
376
+ `- … and ${String(all.length - refs.length)} more reference(s) not resolved ` +
377
+ `(limit ${String(MAX_REFERENCES)}).`,
378
+ );
379
+ }
380
+ return lines.join('\n');
381
+ }
package/src/report.ts CHANGED
@@ -20,11 +20,28 @@ const HEADING: Record<Outcome, string> = {
20
20
  out_of_scope: 'Nothing in scope',
21
21
  };
22
22
 
23
+ /** One de-duplicated finding: the group's highest tier, its first member, and who raised it. */
24
+ export interface DedupGroupView {
25
+ readonly blocking: boolean;
26
+ readonly finding: Finding;
27
+ readonly raisedBy: readonly { readonly lens: string; readonly practices: readonly string[] }[];
28
+ }
29
+
23
30
  export interface ReportContext {
24
31
  /** Where the lens set was read from, so a reader knows whose bar this was. */
25
32
  readonly source: string;
26
33
  /** What was reviewed. */
27
34
  readonly subject: string;
35
+ /** De-duplicated findings (replaces the per-lens finding lists), from `total` raised. */
36
+ readonly dedup?: { readonly groups: readonly DedupGroupView[]; readonly total: number };
37
+ /** Why de-duplication did not happen, said in one line. */
38
+ readonly dedupSkipped?: string;
39
+ }
40
+
41
+ function raisedByText(view: DedupGroupView): string {
42
+ return view.raisedBy
43
+ .map((r) => (r.practices.length === 0 ? r.lens : `${r.lens} (${r.practices.join(', ')})`))
44
+ .join(', ');
28
45
  }
29
46
 
30
47
  /**
@@ -50,6 +67,17 @@ function findingsByLens(findings: readonly Finding[]): Map<string, Finding[]> {
50
67
  return grouped;
51
68
  }
52
69
 
70
+ /** `lens: verdict` plus the lens's own one-line reason when it gave one. */
71
+ function verdictHeading(lens: string, verdict: Verdict): string {
72
+ const result = verdict.results.find((r) => r.lens === lens);
73
+ if (result === undefined) return lens;
74
+ const reason = result.reason === undefined ? '' : result.reason.trim();
75
+ // The model's reason was written for the outcome it gave, not the one tier enforcement left.
76
+ const word =
77
+ result.tierAdjusted === true ? `${result.outcome} (after tier enforcement)` : result.outcome;
78
+ return reason === '' ? `${lens}: ${word}` : `${lens}: ${word}: ${reason}`;
79
+ }
80
+
53
81
  export function renderReport(verdict: Verdict, context: ReportContext): string {
54
82
  const lines: string[] = [
55
83
  `## Review: ${HEADING[verdict.outcome]}`,
@@ -89,24 +117,67 @@ export function renderReport(verdict: Verdict, context: ReportContext): string {
89
117
  const grouped = findingsByLens(verdict.findings);
90
118
  const blockingCount = verdict.findings.filter((finding) => finding.blocking).length;
91
119
  const smellCount = verdict.findings.length - blockingCount;
92
- if (grouped.size > 0) {
120
+ const dedup =
121
+ context.dedup !== undefined && context.dedup.groups.length > 0 ? context.dedup : undefined;
122
+ if (dedup !== undefined) {
123
+ const blocking = dedup.groups.filter((g) => g.blocking).length;
124
+ const smells = dedup.groups.length - blocking;
125
+ lines.push(
126
+ `### ${dedup.groups.length} finding(s)` +
127
+ (smells > 0 ? ` (${blocking} blocking, ${smells} non-blocking)` : '') +
128
+ `, de-duplicated from ${dedup.total} raised`,
129
+ '',
130
+ );
131
+ for (const group of dedup.groups) {
132
+ lines.push(`- [${group.blocking ? 'blocking' : 'smell'}] ${group.finding.summary}`);
133
+ const detail = group.finding.detail;
134
+ if (detail !== undefined && detail !== '') {
135
+ for (const detailLine of detail.split('\n')) {
136
+ lines.push(detailLine === '' ? '' : ` ${detailLine}`);
137
+ }
138
+ }
139
+ lines.push(` raised by: ${raisedByText(group)}`);
140
+ }
141
+ lines.push('');
142
+ // Per-lens verdict lines are unchanged: every lens that reviewed gets its own.
143
+ lines.push('### Lens verdicts', '');
144
+ for (const r of verdict.results) {
145
+ if (r.outcome === 'approved' || r.outcome === 'rejected') {
146
+ lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
147
+ }
148
+ }
149
+ } else if (grouped.size > 0) {
93
150
  lines.push(
94
151
  `### ${verdict.findings.length} finding(s)` +
95
152
  (smellCount > 0 ? ` (${blockingCount} blocking, ${smellCount} non-blocking)` : ''),
96
153
  '',
97
154
  );
98
155
  for (const [lens, findings] of grouped) {
99
- lines.push(`#### ${lens}`, '');
156
+ lines.push(`#### ${verdictHeading(lens, verdict)}`, '');
100
157
  for (const finding of findings) {
101
158
  lines.push(`- [${finding.blocking ? 'blocking' : 'smell'}] ${finding.summary}`);
102
159
  if (finding.detail !== undefined && finding.detail !== '') {
103
- lines.push(` ${finding.detail}`);
160
+ for (const detailLine of finding.detail.split('\n')) {
161
+ lines.push(detailLine === '' ? '' : ` ${detailLine}`);
162
+ }
104
163
  }
105
164
  }
106
165
  lines.push('');
107
166
  }
108
167
  }
109
168
 
169
+ // Every lens gets a line: the approving ones, with no findings, are not otherwise
170
+ // visible. Lenses that did not run or had nothing in scope are listed above.
171
+ const quiet = verdict.results.filter((r) => r.outcome === 'approved' && !grouped.has(r.lens));
172
+ if (dedup === undefined && quiet.length > 0) {
173
+ if (grouped.size === 0) lines.push('### Lens verdicts', '');
174
+ for (const r of quiet) lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
175
+ }
176
+
177
+ if (context.dedupSkipped !== undefined) {
178
+ lines.push(`De-duplication was skipped: ${context.dedupSkipped}.`, '');
179
+ }
180
+
110
181
  // BL-144: a substantiated rejection now wins the outcome WORD even when
111
182
  // lenses remain unrun (see verdict.ts's own header) — but that must never
112
183
  // read as "fix the findings above and this becomes approved". It cannot:
@@ -136,19 +207,22 @@ export function renderReport(verdict: Verdict, context: ReportContext): string {
136
207
  }
137
208
 
138
209
  if (verdict.outcome === 'approved') {
210
+ // What the reader was shown: de-duplicated groups when the findings were merged.
211
+ const shownSmells =
212
+ dedup === undefined ? smellCount : dedup.groups.filter((g) => !g.blocking).length;
139
213
  const base =
140
214
  verdict.outOfScope.length > 0
141
- ? smellCount > 0
215
+ ? shownSmells > 0
142
216
  ? 'Every lens with something in scope reviewed it. Nothing it found held this up. See ' +
143
217
  'above for the lens(es) that had nothing in scope for this change.'
144
218
  : 'Every lens with something in scope reviewed it and found nothing. See above for the ' +
145
219
  'lens(es) that had nothing in scope for this change.'
146
- : smellCount > 0
220
+ : shownSmells > 0
147
221
  ? 'Every lens reviewed. Nothing it found held this up.'
148
222
  : 'Every lens reviewed and none had a finding.';
149
223
  const suffix =
150
- smellCount > 0
151
- ? ` ${smellCount} non-blocking finding(s) are listed above; none of them held this up. ` +
224
+ shownSmells > 0
225
+ ? ` ${shownSmells} non-blocking finding(s) are listed above; none of them held this up. ` +
152
226
  'If this merges as-is, file each as a follow-up issue before merging, referenced ' +
153
227
  'as `owner/repo#123`.'
154
228
  : '';
@@ -188,7 +188,13 @@ export function anthropicReviewer(
188
188
  // The shared half, marked cacheable — `Prompt`'s ordering contract made
189
189
  // real: identical bytes for every lens, sent first, so a five-lens
190
190
  // panel pays for the diff once.
191
- system: [{ type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } }],
191
+ // Unless the prompt says nothing else reads it (`cache: false`): a write nobody reads
192
+ // is pure cost.
193
+ system: [
194
+ prompt.cache === false
195
+ ? { type: 'text', text: prompt.shared }
196
+ : { type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } },
197
+ ],
192
198
  messages: [{ role: 'user', content: prompt.perLens }],
193
199
  });
194
200
 
package/src/run-review.ts CHANGED
@@ -40,6 +40,10 @@ import { selectReviewer } from './reviewer-selection.js';
40
40
  import type { HttpPost } from './reviewer-anthropic.js';
41
41
  import { describeThrown, type RawCall } from './raw-call.js';
42
42
  import { normaliseReviewerReply, type Reviewer } from './panel.js';
43
+ import { warmFirst } from './warm-first.js';
44
+ import { enforceTier } from './tier.js';
45
+ import { dedupAuditLines, dedupFindings, numberFindings, toGroupViews } from './dedup.js';
46
+ import type { DedupGroupView } from './report.js';
43
47
 
44
48
  export type { ReviewAccount } from './review-account.js';
45
49
 
@@ -73,6 +77,21 @@ export interface ReviewNodeInput {
73
77
  * leaves every existing call site building its Reviewer exactly as
74
78
  * before. */
75
79
  readonly reviewer?: Reviewer;
80
+ /** Run the first lens alone, then fan out the rest, so they read the prompt
81
+ * cache it wrote. Only applies with 2 or more lenses. Off when absent. */
82
+ readonly warmFirst?: boolean;
83
+ /** Per lens id: the signal lines its [blocking] findings must quote (see tier.ts). A lens
84
+ * with no entry is left as it was. Absent: no tier enforcement. */
85
+ readonly tierSignals?: Readonly<Record<string, readonly string[]>>;
86
+ /** Per lens id: just the `_Reject:_` lines of `tierSignals`; a smell quoting one is promoted. */
87
+ readonly tierRejects?: Readonly<Record<string, readonly string[]>>;
88
+ /** Every practice id in the corpus; ignored when matching a quote (see tier.ts). */
89
+ readonly practiceIds?: readonly string[];
90
+ /** After the panel (and tier enforcement), make ONE extra call through the same reviewer to
91
+ * group findings that describe the same defect (see dedup.ts). Off when absent. */
92
+ readonly dedup?: boolean;
93
+ /** Per lens id: its practices, so a de-duplicated finding can name them. */
94
+ readonly practiceNames?: Readonly<Record<string, readonly { id: string; title: string }[]>>;
76
95
  }
77
96
 
78
97
  export interface ReviewNodeResult {
@@ -83,6 +102,12 @@ export interface ReviewNodeResult {
83
102
  /** Every prompt as dispatched, keyed by lens id — proof the fan shares
84
103
  * one prefix, rather than merely claiming it. */
85
104
  readonly prompts: ReadonlyMap<string, Prompt>;
105
+ /** How many [blocking] findings tier enforcement downgraded to smells (0 when none). */
106
+ readonly downgraded: number;
107
+ /** How many smells tier enforcement promoted to [blocking] (0 when none). */
108
+ readonly promoted: number;
109
+ /** Stderr lines, one per member of every merged dedup group (empty when none merged). */
110
+ readonly dedupAudit: readonly string[];
86
111
  }
87
112
 
88
113
  // RECORD-NEVER-PREDICT discipline: `onCall` is OPTIONAL so every existing
@@ -160,17 +185,55 @@ export async function runReviewNode(
160
185
  }
161
186
  };
162
187
 
163
- const { verdict, prompts } = await runPanel(
164
- input.lensSet,
165
- input.subject,
166
- observedReviewer,
167
- input.practices,
168
- );
188
+ // Outside the observer, so each lens's recorded wall time is its own call,
189
+ // not the time it spent waiting for the warm-up.
190
+ const dispatched =
191
+ input.warmFirst === true && input.lensSet.lenses.length >= 2
192
+ ? warmFirst(observedReviewer)
193
+ : observedReviewer;
194
+
195
+ const panel = await runPanel(input.lensSet, input.subject, dispatched, input.practices);
196
+ const prompts = panel.prompts;
197
+
198
+ // C1 runs before C2: de-duplication sees the tiers as enforced.
199
+ const tiered =
200
+ input.tierSignals === undefined
201
+ ? { verdict: panel.verdict, downgraded: 0, promoted: 0 }
202
+ : enforceTier(panel.verdict, input.tierSignals, {
203
+ ...(input.tierRejects === undefined ? {} : { rejects: input.tierRejects }),
204
+ ...(input.practiceIds === undefined ? {} : { practiceIds: input.practiceIds }),
205
+ });
206
+ const verdict = tiered.verdict;
207
+
208
+ let dedup: { groups: DedupGroupView[]; total: number } | undefined;
209
+ let dedupSkipped: string | undefined;
210
+ let dedupAudit: readonly string[] = [];
211
+ if (input.dedup === true && verdict.findings.length >= 2) {
212
+ const numbered = numberFindings(verdict.findings);
213
+ // The observed reviewer, outside warm-first: the extra call is recorded like a lens call.
214
+ const outcome = await dedupFindings(numbered, observedReviewer);
215
+ if (outcome.ok) {
216
+ dedup = { groups: toGroupViews(outcome.groups, input.practiceNames), total: numbered.length };
217
+ dedupAudit = dedupAuditLines(outcome.groups);
218
+ } else {
219
+ dedupSkipped = outcome.reason;
220
+ }
221
+ }
169
222
 
170
223
  const report = renderReport(verdict, {
171
224
  source: input.lensSetSource,
172
225
  subject: input.subject.title,
226
+ ...(dedup === undefined ? {} : { dedup }),
227
+ ...(dedupSkipped === undefined ? {} : { dedupSkipped }),
173
228
  });
174
229
 
175
- return { verdict, report, exitCode: exitCodeFor(verdict.outcome), prompts };
230
+ return {
231
+ verdict,
232
+ report,
233
+ exitCode: exitCodeFor(verdict.outcome),
234
+ prompts,
235
+ downgraded: tiered.downgraded,
236
+ promoted: tiered.promoted,
237
+ dedupAudit,
238
+ };
176
239
  }