@verevoir/review 0.2.0 → 0.3.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 (53) 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 +9 -0
  5. package/dist/cli/footer.js +59 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +507 -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 +40 -0
  15. package/dist/dedup.js +151 -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 +69 -0
  22. package/dist/practice-lenses.js +218 -0
  23. package/dist/prompt.d.ts +23 -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 +56 -3
  29. package/dist/run-review.d.ts +16 -0
  30. package/dist/run-review.js +37 -2
  31. package/dist/source-access.d.ts +8 -0
  32. package/dist/source-access.js +1 -0
  33. package/dist/subject-context-render.d.ts +8 -0
  34. package/dist/subject-context-render.js +29 -0
  35. package/dist/tier.d.ts +20 -0
  36. package/dist/tier.js +88 -0
  37. package/dist/warm-first.d.ts +2 -0
  38. package/dist/warm-first.js +21 -0
  39. package/lenses.default.json +30 -0
  40. package/package.json +5 -1
  41. package/src/cli/bin.ts +61 -0
  42. package/src/cli/footer.ts +78 -0
  43. package/src/cli/review.ts +647 -0
  44. package/src/context-files.ts +194 -0
  45. package/src/dedup.ts +187 -0
  46. package/src/parseReply.ts +58 -7
  47. package/src/practice-lenses.ts +265 -0
  48. package/src/prompt.ts +44 -0
  49. package/src/references.ts +381 -0
  50. package/src/report.ts +71 -3
  51. package/src/run-review.ts +55 -7
  52. package/src/tier.ts +103 -0
  53. 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,14 @@ 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
+ return reason === '' ? `${lens}: ${result.outcome}` : `${lens}: ${result.outcome}: ${reason}`;
53
+ }
41
54
  export function renderReport(verdict, context) {
42
55
  const lines = [
43
56
  `## Review: ${HEADING[verdict.outcome]}`,
@@ -62,20 +75,60 @@ export function renderReport(verdict, context) {
62
75
  const grouped = findingsByLens(verdict.findings);
63
76
  const blockingCount = verdict.findings.filter((finding) => finding.blocking).length;
64
77
  const smellCount = verdict.findings.length - blockingCount;
65
- if (grouped.size > 0) {
78
+ const dedup = context.dedup !== undefined && context.dedup.groups.length > 0 ? context.dedup : undefined;
79
+ if (dedup !== undefined) {
80
+ const blocking = dedup.groups.filter((g) => g.blocking).length;
81
+ const smells = dedup.groups.length - blocking;
82
+ lines.push(`### ${dedup.groups.length} finding(s)` +
83
+ (smells > 0 ? ` (${blocking} blocking, ${smells} non-blocking)` : '') +
84
+ `, de-duplicated from ${dedup.total} raised`, '');
85
+ for (const group of dedup.groups) {
86
+ lines.push(`- [${group.blocking ? 'blocking' : 'smell'}] ${group.finding.summary}`);
87
+ const detail = group.finding.detail;
88
+ if (detail !== undefined && detail !== '') {
89
+ for (const detailLine of detail.split('\n')) {
90
+ lines.push(detailLine === '' ? '' : ` ${detailLine}`);
91
+ }
92
+ }
93
+ lines.push(` raised by: ${raisedByText(group)}`);
94
+ }
95
+ lines.push('');
96
+ // Per-lens verdict lines are unchanged: every lens that reviewed gets its own.
97
+ lines.push('### Lens verdicts', '');
98
+ for (const r of verdict.results) {
99
+ if (r.outcome === 'approved' || r.outcome === 'rejected') {
100
+ lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
101
+ }
102
+ }
103
+ }
104
+ else if (grouped.size > 0) {
66
105
  lines.push(`### ${verdict.findings.length} finding(s)` +
67
106
  (smellCount > 0 ? ` (${blockingCount} blocking, ${smellCount} non-blocking)` : ''), '');
68
107
  for (const [lens, findings] of grouped) {
69
- lines.push(`#### ${lens}`, '');
108
+ lines.push(`#### ${verdictHeading(lens, verdict)}`, '');
70
109
  for (const finding of findings) {
71
110
  lines.push(`- [${finding.blocking ? 'blocking' : 'smell'}] ${finding.summary}`);
72
111
  if (finding.detail !== undefined && finding.detail !== '') {
73
- lines.push(` ${finding.detail}`);
112
+ for (const detailLine of finding.detail.split('\n')) {
113
+ lines.push(detailLine === '' ? '' : ` ${detailLine}`);
114
+ }
74
115
  }
75
116
  }
76
117
  lines.push('');
77
118
  }
78
119
  }
120
+ // Every lens gets a line: the approving ones, with no findings, are not otherwise
121
+ // visible. Lenses that did not run or had nothing in scope are listed above.
122
+ const quiet = verdict.results.filter((r) => r.outcome === 'approved' && !grouped.has(r.lens));
123
+ if (dedup === undefined && quiet.length > 0) {
124
+ if (grouped.size === 0)
125
+ lines.push('### Lens verdicts', '');
126
+ for (const r of quiet)
127
+ lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
128
+ }
129
+ if (context.dedupSkipped !== undefined) {
130
+ lines.push(`De-duplication was skipped: ${context.dedupSkipped}.`, '');
131
+ }
79
132
  // BL-144: a substantiated rejection now wins the outcome WORD even when
80
133
  // lenses remain unrun (see verdict.ts's own header) — but that must never
81
134
  // read as "fix the findings above and this becomes approved". It cannot:
@@ -35,6 +35,20 @@ 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
+ /** After the panel (and tier enforcement), make ONE extra call through the same reviewer to
45
+ * group findings that describe the same defect (see dedup.ts). Off when absent. */
46
+ readonly dedup?: boolean;
47
+ /** Per lens id: its practices, so a de-duplicated finding can name them. */
48
+ readonly practiceNames?: Readonly<Record<string, readonly {
49
+ id: string;
50
+ title: string;
51
+ }[]>>;
38
52
  }
39
53
  export interface ReviewNodeResult {
40
54
  readonly verdict: Verdict;
@@ -44,5 +58,7 @@ export interface ReviewNodeResult {
44
58
  /** Every prompt as dispatched, keyed by lens id — proof the fan shares
45
59
  * one prefix, rather than merely claiming it. */
46
60
  readonly prompts: ReadonlyMap<string, Prompt>;
61
+ /** How many [blocking] findings tier enforcement downgraded to smells (0 when none). */
62
+ readonly downgraded: number;
47
63
  }
48
64
  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 { 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,42 @@ 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 }
120
+ : enforceTier(panel.verdict, input.tierSignals);
121
+ const verdict = tiered.verdict;
122
+ let dedup;
123
+ let dedupSkipped;
124
+ if (input.dedup === true && verdict.findings.length >= 2) {
125
+ const numbered = numberFindings(verdict.findings);
126
+ // The observed reviewer, outside warm-first: the extra call is recorded like a lens call.
127
+ const outcome = await dedupFindings(numbered, observedReviewer);
128
+ if (outcome.ok) {
129
+ dedup = { groups: toGroupViews(outcome.groups, input.practiceNames), total: numbered.length };
130
+ }
131
+ else {
132
+ dedupSkipped = outcome.reason;
133
+ }
134
+ }
108
135
  const report = renderReport(verdict, {
109
136
  source: input.lensSetSource,
110
137
  subject: input.subject.title,
138
+ ...(dedup === undefined ? {} : { dedup }),
139
+ ...(dedupSkipped === undefined ? {} : { dedupSkipped }),
111
140
  });
112
- return { verdict, report, exitCode: exitCodeFor(verdict.outcome), prompts };
141
+ return {
142
+ verdict,
143
+ report,
144
+ exitCode: exitCodeFor(verdict.outcome),
145
+ prompts,
146
+ downgraded: tiered.downgraded,
147
+ };
113
148
  }
@@ -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,20 @@
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
+ export declare function normaliseForQuote(text: string): string;
7
+ /** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
8
+ export declare function quotesSignal(text: string, signals: readonly string[]): boolean;
9
+ export interface TierResult {
10
+ readonly verdict: Verdict;
11
+ /** How many findings were downgraded. */
12
+ readonly downgraded: number;
13
+ }
14
+ /**
15
+ * Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, then
16
+ * re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
17
+ * `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
18
+ * downgrade the verdict is returned as it was.
19
+ */
20
+ export declare function enforceTier(verdict: Verdict, signals: Readonly<Record<string, readonly string[]>>): TierResult;
package/dist/tier.js ADDED
@@ -0,0 +1,88 @@
1
+ // Enforces the tier in code. A derived lens is told to tag a finding [blocking] only when it
2
+ // quotes the signal it fails, but a model honours that loosely. After parsing, every [blocking]
3
+ // finding of a derived lens is checked against THAT lens's own practices' `_Reject:_` /
4
+ // `_Expect:_` lines; one that quotes none is downgraded to [smell] and the lens's outcome is
5
+ // recomputed. Deterministic: no model call.
6
+ //
7
+ // MATCHING RULE. Both sides are normalised (lower-cased; markdown emphasis `*` `_` and
8
+ // backticks removed; quote marks removed; runs of whitespace collapsed to one space). A
9
+ // finding (summary + detail) quotes a signal line when it contains a contiguous
10
+ // MIN_QUOTE-character (24) run of the signal's normalised text, or the whole normalised line
11
+ // when the line is shorter than that.
12
+ import { aggregate } from './verdict.js';
13
+ /** The shortest verbatim run of a signal line that counts as quoting it. */
14
+ export const MIN_QUOTE = 24;
15
+ /** Appended to the summary of a finding that was downgraded. */
16
+ export const DOWNGRADE_NOTE = ' (downgraded: no signal quoted)';
17
+ export function normaliseForQuote(text) {
18
+ return text
19
+ .replace(/[*_`]/g, '')
20
+ .replace(/["'‘’“”«»]/g, '')
21
+ .replace(/\s+/g, ' ')
22
+ .trim()
23
+ .toLowerCase();
24
+ }
25
+ /** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
26
+ export function quotesSignal(text, signals) {
27
+ const haystack = normaliseForQuote(text);
28
+ for (const signal of signals) {
29
+ const needle = normaliseForQuote(signal);
30
+ if (needle === '')
31
+ continue;
32
+ if (needle.length <= MIN_QUOTE) {
33
+ if (haystack.includes(needle))
34
+ return true;
35
+ continue;
36
+ }
37
+ for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
38
+ if (haystack.includes(needle.slice(i, i + MIN_QUOTE)))
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+ // Same test as verdict.ts's: a [blocking] line whose text begins "none" says "nothing found".
45
+ const NONE_SENTINEL = /^none(?:\s*$|\s*[-—]\s)/;
46
+ function isNone(finding) {
47
+ return NONE_SENTINEL.test(finding.summary.trim());
48
+ }
49
+ function hasBlocking(findings) {
50
+ return findings.some((f) => f.blocking && !isNone(f));
51
+ }
52
+ /**
53
+ * Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, then
54
+ * re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
55
+ * `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
56
+ * downgrade the verdict is returned as it was.
57
+ */
58
+ export function enforceTier(verdict, signals) {
59
+ let downgraded = 0;
60
+ const results = verdict.results.map((result) => {
61
+ if (!Object.prototype.hasOwnProperty.call(signals, result.lens))
62
+ return result;
63
+ const own = signals[result.lens] ?? [];
64
+ let changed = false;
65
+ const findings = result.findings.map((finding) => {
66
+ if (!finding.blocking || isNone(finding))
67
+ return finding;
68
+ if (quotesSignal(`${finding.summary}\n${finding.detail ?? ''}`, own))
69
+ return finding;
70
+ changed = true;
71
+ downgraded++;
72
+ return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
73
+ });
74
+ if (!changed)
75
+ return result;
76
+ const outcome = result.outcome === 'rejected' && !hasBlocking(findings)
77
+ ? 'approved'
78
+ : result.outcome;
79
+ return { ...result, outcome, findings };
80
+ });
81
+ if (downgraded === 0)
82
+ return { verdict, downgraded };
83
+ // Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
84
+ return {
85
+ verdict: aggregate(verdict.results.map((r) => r.lens), results),
86
+ downgraded,
87
+ };
88
+ }