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