@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.
- package/README.md +132 -2
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +46 -0
- package/dist/cli/footer.d.ts +9 -0
- package/dist/cli/footer.js +59 -0
- package/dist/cli/review.d.ts +45 -0
- package/dist/cli/review.js +507 -0
- package/dist/context-assembler.d.ts +9 -0
- package/dist/context-assembler.js +118 -0
- package/dist/context-files.d.ts +33 -0
- package/dist/context-files.js +174 -0
- package/dist/context-types.d.ts +28 -0
- package/dist/context-types.js +5 -0
- package/dist/dedup.d.ts +40 -0
- package/dist/dedup.js +151 -0
- package/dist/diff-hunks.d.ts +17 -0
- package/dist/diff-hunks.js +73 -0
- package/dist/fs-source-access.d.ts +7 -0
- package/dist/fs-source-access.js +39 -0
- package/dist/parseReply.js +58 -7
- package/dist/practice-lenses.d.ts +69 -0
- package/dist/practice-lenses.js +218 -0
- package/dist/prompt.d.ts +23 -0
- package/dist/prompt.js +18 -0
- package/dist/references.d.ts +44 -0
- package/dist/references.js +312 -0
- package/dist/report.d.ts +17 -1
- package/dist/report.js +56 -3
- package/dist/run-review.d.ts +16 -0
- package/dist/run-review.js +37 -2
- package/dist/source-access.d.ts +8 -0
- package/dist/source-access.js +1 -0
- package/dist/subject-context-render.d.ts +8 -0
- package/dist/subject-context-render.js +29 -0
- package/dist/tier.d.ts +20 -0
- package/dist/tier.js +88 -0
- package/dist/warm-first.d.ts +2 -0
- package/dist/warm-first.js +21 -0
- package/lenses.default.json +30 -0
- package/package.json +5 -1
- package/src/cli/bin.ts +61 -0
- package/src/cli/footer.ts +78 -0
- package/src/cli/review.ts +647 -0
- package/src/context-files.ts +194 -0
- package/src/dedup.ts +187 -0
- package/src/parseReply.ts +58 -7
- package/src/practice-lenses.ts +265 -0
- package/src/prompt.ts +44 -0
- package/src/references.ts +381 -0
- package/src/report.ts +71 -3
- package/src/run-review.ts +55 -7
- package/src/tier.ts +103 -0
- 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,14 @@ 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
|
+
return reason === '' ? `${lens}: ${result.outcome}` : `${lens}: ${result.outcome}: ${reason}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
export function renderReport(verdict: Verdict, context: ReportContext): string {
|
|
54
79
|
const lines: string[] = [
|
|
55
80
|
`## Review: ${HEADING[verdict.outcome]}`,
|
|
@@ -89,24 +114,67 @@ export function renderReport(verdict: Verdict, context: ReportContext): string {
|
|
|
89
114
|
const grouped = findingsByLens(verdict.findings);
|
|
90
115
|
const blockingCount = verdict.findings.filter((finding) => finding.blocking).length;
|
|
91
116
|
const smellCount = verdict.findings.length - blockingCount;
|
|
92
|
-
|
|
117
|
+
const dedup =
|
|
118
|
+
context.dedup !== undefined && context.dedup.groups.length > 0 ? context.dedup : undefined;
|
|
119
|
+
if (dedup !== undefined) {
|
|
120
|
+
const blocking = dedup.groups.filter((g) => g.blocking).length;
|
|
121
|
+
const smells = dedup.groups.length - blocking;
|
|
122
|
+
lines.push(
|
|
123
|
+
`### ${dedup.groups.length} finding(s)` +
|
|
124
|
+
(smells > 0 ? ` (${blocking} blocking, ${smells} non-blocking)` : '') +
|
|
125
|
+
`, de-duplicated from ${dedup.total} raised`,
|
|
126
|
+
'',
|
|
127
|
+
);
|
|
128
|
+
for (const group of dedup.groups) {
|
|
129
|
+
lines.push(`- [${group.blocking ? 'blocking' : 'smell'}] ${group.finding.summary}`);
|
|
130
|
+
const detail = group.finding.detail;
|
|
131
|
+
if (detail !== undefined && detail !== '') {
|
|
132
|
+
for (const detailLine of detail.split('\n')) {
|
|
133
|
+
lines.push(detailLine === '' ? '' : ` ${detailLine}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lines.push(` raised by: ${raisedByText(group)}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
// Per-lens verdict lines are unchanged: every lens that reviewed gets its own.
|
|
140
|
+
lines.push('### Lens verdicts', '');
|
|
141
|
+
for (const r of verdict.results) {
|
|
142
|
+
if (r.outcome === 'approved' || r.outcome === 'rejected') {
|
|
143
|
+
lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else if (grouped.size > 0) {
|
|
93
147
|
lines.push(
|
|
94
148
|
`### ${verdict.findings.length} finding(s)` +
|
|
95
149
|
(smellCount > 0 ? ` (${blockingCount} blocking, ${smellCount} non-blocking)` : ''),
|
|
96
150
|
'',
|
|
97
151
|
);
|
|
98
152
|
for (const [lens, findings] of grouped) {
|
|
99
|
-
lines.push(`#### ${lens}`, '');
|
|
153
|
+
lines.push(`#### ${verdictHeading(lens, verdict)}`, '');
|
|
100
154
|
for (const finding of findings) {
|
|
101
155
|
lines.push(`- [${finding.blocking ? 'blocking' : 'smell'}] ${finding.summary}`);
|
|
102
156
|
if (finding.detail !== undefined && finding.detail !== '') {
|
|
103
|
-
|
|
157
|
+
for (const detailLine of finding.detail.split('\n')) {
|
|
158
|
+
lines.push(detailLine === '' ? '' : ` ${detailLine}`);
|
|
159
|
+
}
|
|
104
160
|
}
|
|
105
161
|
}
|
|
106
162
|
lines.push('');
|
|
107
163
|
}
|
|
108
164
|
}
|
|
109
165
|
|
|
166
|
+
// Every lens gets a line: the approving ones, with no findings, are not otherwise
|
|
167
|
+
// visible. Lenses that did not run or had nothing in scope are listed above.
|
|
168
|
+
const quiet = verdict.results.filter((r) => r.outcome === 'approved' && !grouped.has(r.lens));
|
|
169
|
+
if (dedup === undefined && quiet.length > 0) {
|
|
170
|
+
if (grouped.size === 0) lines.push('### Lens verdicts', '');
|
|
171
|
+
for (const r of quiet) lines.push(`#### ${verdictHeading(r.lens, verdict)}`, '');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (context.dedupSkipped !== undefined) {
|
|
175
|
+
lines.push(`De-duplication was skipped: ${context.dedupSkipped}.`, '');
|
|
176
|
+
}
|
|
177
|
+
|
|
110
178
|
// BL-144: a substantiated rejection now wins the outcome WORD even when
|
|
111
179
|
// lenses remain unrun (see verdict.ts's own header) — but that must never
|
|
112
180
|
// read as "fix the findings above and this becomes approved". It cannot:
|
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 { 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,17 @@ 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
|
+
/** After the panel (and tier enforcement), make ONE extra call through the same reviewer to
|
|
87
|
+
* group findings that describe the same defect (see dedup.ts). Off when absent. */
|
|
88
|
+
readonly dedup?: boolean;
|
|
89
|
+
/** Per lens id: its practices, so a de-duplicated finding can name them. */
|
|
90
|
+
readonly practiceNames?: Readonly<Record<string, readonly { id: string; title: string }[]>>;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
export interface ReviewNodeResult {
|
|
@@ -83,6 +98,8 @@ export interface ReviewNodeResult {
|
|
|
83
98
|
/** Every prompt as dispatched, keyed by lens id — proof the fan shares
|
|
84
99
|
* one prefix, rather than merely claiming it. */
|
|
85
100
|
readonly prompts: ReadonlyMap<string, Prompt>;
|
|
101
|
+
/** How many [blocking] findings tier enforcement downgraded to smells (0 when none). */
|
|
102
|
+
readonly downgraded: number;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
// RECORD-NEVER-PREDICT discipline: `onCall` is OPTIONAL so every existing
|
|
@@ -160,17 +177,48 @@ export async function runReviewNode(
|
|
|
160
177
|
}
|
|
161
178
|
};
|
|
162
179
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
// Outside the observer, so each lens's recorded wall time is its own call,
|
|
181
|
+
// not the time it spent waiting for the warm-up.
|
|
182
|
+
const dispatched =
|
|
183
|
+
input.warmFirst === true && input.lensSet.lenses.length >= 2
|
|
184
|
+
? warmFirst(observedReviewer)
|
|
185
|
+
: observedReviewer;
|
|
186
|
+
|
|
187
|
+
const panel = await runPanel(input.lensSet, input.subject, dispatched, input.practices);
|
|
188
|
+
const prompts = panel.prompts;
|
|
189
|
+
|
|
190
|
+
// C1 runs before C2: de-duplication sees the tiers as enforced.
|
|
191
|
+
const tiered =
|
|
192
|
+
input.tierSignals === undefined
|
|
193
|
+
? { verdict: panel.verdict, downgraded: 0 }
|
|
194
|
+
: enforceTier(panel.verdict, input.tierSignals);
|
|
195
|
+
const verdict = tiered.verdict;
|
|
196
|
+
|
|
197
|
+
let dedup: { groups: DedupGroupView[]; total: number } | undefined;
|
|
198
|
+
let dedupSkipped: string | undefined;
|
|
199
|
+
if (input.dedup === true && verdict.findings.length >= 2) {
|
|
200
|
+
const numbered = numberFindings(verdict.findings);
|
|
201
|
+
// The observed reviewer, outside warm-first: the extra call is recorded like a lens call.
|
|
202
|
+
const outcome = await dedupFindings(numbered, observedReviewer);
|
|
203
|
+
if (outcome.ok) {
|
|
204
|
+
dedup = { groups: toGroupViews(outcome.groups, input.practiceNames), total: numbered.length };
|
|
205
|
+
} else {
|
|
206
|
+
dedupSkipped = outcome.reason;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
169
209
|
|
|
170
210
|
const report = renderReport(verdict, {
|
|
171
211
|
source: input.lensSetSource,
|
|
172
212
|
subject: input.subject.title,
|
|
213
|
+
...(dedup === undefined ? {} : { dedup }),
|
|
214
|
+
...(dedupSkipped === undefined ? {} : { dedupSkipped }),
|
|
173
215
|
});
|
|
174
216
|
|
|
175
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
verdict,
|
|
219
|
+
report,
|
|
220
|
+
exitCode: exitCodeFor(verdict.outcome),
|
|
221
|
+
prompts,
|
|
222
|
+
downgraded: tiered.downgraded,
|
|
223
|
+
};
|
|
176
224
|
}
|
package/src/tier.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
|
|
13
|
+
import { aggregate } from './verdict.js';
|
|
14
|
+
import type { Finding, LensResult, Verdict } from './verdict.js';
|
|
15
|
+
|
|
16
|
+
/** The shortest verbatim run of a signal line that counts as quoting it. */
|
|
17
|
+
export const MIN_QUOTE = 24;
|
|
18
|
+
|
|
19
|
+
/** Appended to the summary of a finding that was downgraded. */
|
|
20
|
+
export const DOWNGRADE_NOTE = ' (downgraded: no signal quoted)';
|
|
21
|
+
|
|
22
|
+
export function normaliseForQuote(text: string): string {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/[*_`]/g, '')
|
|
25
|
+
.replace(/["'‘’“”«»]/g, '')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
|
|
32
|
+
export function quotesSignal(text: string, signals: readonly string[]): boolean {
|
|
33
|
+
const haystack = normaliseForQuote(text);
|
|
34
|
+
for (const signal of signals) {
|
|
35
|
+
const needle = normaliseForQuote(signal);
|
|
36
|
+
if (needle === '') continue;
|
|
37
|
+
if (needle.length <= MIN_QUOTE) {
|
|
38
|
+
if (haystack.includes(needle)) return true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
|
|
42
|
+
if (haystack.includes(needle.slice(i, i + MIN_QUOTE))) return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Same test as verdict.ts's: a [blocking] line whose text begins "none" says "nothing found".
|
|
49
|
+
const NONE_SENTINEL = /^none(?:\s*$|\s*[-—]\s)/;
|
|
50
|
+
|
|
51
|
+
function isNone(finding: Finding): boolean {
|
|
52
|
+
return NONE_SENTINEL.test(finding.summary.trim());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasBlocking(findings: readonly Finding[]): boolean {
|
|
56
|
+
return findings.some((f) => f.blocking && !isNone(f));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TierResult {
|
|
60
|
+
readonly verdict: Verdict;
|
|
61
|
+
/** How many findings were downgraded. */
|
|
62
|
+
readonly downgraded: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, then
|
|
67
|
+
* re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
|
|
68
|
+
* `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
|
|
69
|
+
* downgrade the verdict is returned as it was.
|
|
70
|
+
*/
|
|
71
|
+
export function enforceTier(
|
|
72
|
+
verdict: Verdict,
|
|
73
|
+
signals: Readonly<Record<string, readonly string[]>>,
|
|
74
|
+
): TierResult {
|
|
75
|
+
let downgraded = 0;
|
|
76
|
+
const results = verdict.results.map((result): LensResult => {
|
|
77
|
+
if (!Object.prototype.hasOwnProperty.call(signals, result.lens)) return result;
|
|
78
|
+
const own = signals[result.lens] ?? [];
|
|
79
|
+
let changed = false;
|
|
80
|
+
const findings = result.findings.map((finding): Finding => {
|
|
81
|
+
if (!finding.blocking || isNone(finding)) return finding;
|
|
82
|
+
if (quotesSignal(`${finding.summary}\n${finding.detail ?? ''}`, own)) return finding;
|
|
83
|
+
changed = true;
|
|
84
|
+
downgraded++;
|
|
85
|
+
return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
|
|
86
|
+
});
|
|
87
|
+
if (!changed) return result;
|
|
88
|
+
const outcome =
|
|
89
|
+
result.outcome === 'rejected' && !hasBlocking(findings)
|
|
90
|
+
? ('approved' as const)
|
|
91
|
+
: result.outcome;
|
|
92
|
+
return { ...result, outcome, findings };
|
|
93
|
+
});
|
|
94
|
+
if (downgraded === 0) return { verdict, downgraded };
|
|
95
|
+
// Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
|
|
96
|
+
return {
|
|
97
|
+
verdict: aggregate(
|
|
98
|
+
verdict.results.map((r) => r.lens),
|
|
99
|
+
results,
|
|
100
|
+
),
|
|
101
|
+
downgraded,
|
|
102
|
+
};
|
|
103
|
+
}
|