@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,507 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// The `aigency-review` command line: the v5 antagonistic panel, runnable from
|
|
3
|
+
// any repository (including a public one) with `npx`.
|
|
4
|
+
//
|
|
5
|
+
// PORTED FROM verevoir/aigency-governance (src/cli/review.ts, src/cli/run.ts
|
|
6
|
+
// and src/review/settings.ts), so a repository that cannot call governance's
|
|
7
|
+
// private reusable workflow gets the same behaviour: settings are resolved
|
|
8
|
+
// first, then the diff, then the lens set, and only then does anything reach
|
|
9
|
+
// a model. Every failure before a verdict is `could_not_run` and red -- never
|
|
10
|
+
// a pass. See the README's "Command line" section for what was deliberately
|
|
11
|
+
// not ported.
|
|
12
|
+
//
|
|
13
|
+
// `main` takes everything that touches the world as an argument (`io`,
|
|
14
|
+
// `deps`), so tests drive it with fakes and no process, disk, git or network.
|
|
15
|
+
// The bin wrapper (bin.ts) is the only place real ones are constructed.
|
|
16
|
+
//
|
|
17
|
+
// BUNDLED DEFAULT LENSES: `lenses.default.json` at the package root is a copy
|
|
18
|
+
// of aigency-governance's review/lenses.json at commit de238aa. It is a
|
|
19
|
+
// snapshot, not a live read: refresh it deliberately when governance's set
|
|
20
|
+
// changes.
|
|
21
|
+
import { runReviewNode } from '../run-review.js';
|
|
22
|
+
import { parseLensSet } from '../lensSet.js';
|
|
23
|
+
import { buildPrompt, renderPrompt } from '../prompt.js';
|
|
24
|
+
import { deriveLensSet, extractBar } from '../practice-lenses.js';
|
|
25
|
+
import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
|
|
26
|
+
import { buildReferences } from '../references.js';
|
|
27
|
+
import { renderFooter, tierLine, usageLines } from './footer.js';
|
|
28
|
+
/** Governance's pinned default, sent to both routes. */
|
|
29
|
+
export const DEFAULT_MODEL = 'claude-opus-5-5';
|
|
30
|
+
/** Governance's default output-token budget (billed route only). */
|
|
31
|
+
export const DEFAULT_MAX_TOKENS = 65536;
|
|
32
|
+
/** What the prompt's "## The change" shows when no --description-file was given. */
|
|
33
|
+
export const NO_DESCRIPTION_NOTE = 'No PR description was supplied; do not judge the description.';
|
|
34
|
+
/** What the prompt's "## Checks on this change" shows when no --checks-file was given. */
|
|
35
|
+
export const NO_CHECKS_NOTE = 'No check results were supplied; do not assume the tests pass.';
|
|
36
|
+
/** Ends the checks section when --checks-file is given, so a red check is reported once. */
|
|
37
|
+
export const RED_CI_NOTE = 'If a check fails, report it once, then review the rest of the change on its own merits, ' +
|
|
38
|
+
'as if that failure were fixed.';
|
|
39
|
+
const USAGE = [
|
|
40
|
+
'Usage: aigency-review [--base <ref>] [--title <text>] [--lenses <path>]',
|
|
41
|
+
'',
|
|
42
|
+
' --base <ref> review `git diff --merge-base <ref> HEAD` (needs full history:',
|
|
43
|
+
' checkout with fetch-depth: 0). Without it the diff is read from stdin.',
|
|
44
|
+
' --title <text> the title shown at the top of the report (default: "this change")',
|
|
45
|
+
' --lenses <path> a lens set JSON file (else AIGENCY_REVIEW_LENSES, else the bundled set)',
|
|
46
|
+
' --practices <dir> derive one lens per `lens:` front-matter value from the practice',
|
|
47
|
+
' markdown files in <dir> (cannot be combined with --lenses)',
|
|
48
|
+
' --print-lenses with --practices: print the derived lenses as JSON and exit 0,',
|
|
49
|
+
' with no reviewer call and no diff',
|
|
50
|
+
' --print-prompt <lens-id> print the exact prompt that lens would be sent for the diff',
|
|
51
|
+
' (stdin or --base) and exit 0, with no reviewer call',
|
|
52
|
+
" --context-root <dir> a tree at the change's head: each file the diff touches is shown",
|
|
53
|
+
' in full (16 KB or smaller). Defaults to the current directory with --base;',
|
|
54
|
+
' with stdin and no --context-root there is no file context',
|
|
55
|
+
' --description-file <path> the PR description, shown under "## The change"; without it the',
|
|
56
|
+
' prompt says no description was supplied',
|
|
57
|
+
' --checks-file <path> CI check results, shown verbatim under "## Checks on this change";',
|
|
58
|
+
' without it the prompt says no check results were supplied',
|
|
59
|
+
' --warm-first / --no-warm-first run the first lens alone so the rest read the prompt cache',
|
|
60
|
+
' it wrote (default: on with 2 or more lenses; slower, cheaper)',
|
|
61
|
+
' --no-dedup skip the extra model call that merges findings describing the same',
|
|
62
|
+
' defect across lenses (default: on when 2 or more findings are raised)',
|
|
63
|
+
' -h, --help print this and exit 0',
|
|
64
|
+
'',
|
|
65
|
+
'Environment: ANTHROPIC_API_KEY, ANTHROPIC_API_KEY_FILE, AIGENCY_REVIEW_LENSES,',
|
|
66
|
+
'AIGENCY_REVIEW_MODEL, AIGENCY_REVIEW_MAX_TOKENS, GITHUB_STEP_SUMMARY.',
|
|
67
|
+
].join('\n');
|
|
68
|
+
function parseArgs(argv) {
|
|
69
|
+
let base;
|
|
70
|
+
let title = 'this change';
|
|
71
|
+
let lenses;
|
|
72
|
+
let help = false;
|
|
73
|
+
let practices;
|
|
74
|
+
let printLenses = false;
|
|
75
|
+
let printPrompt;
|
|
76
|
+
let contextRoot;
|
|
77
|
+
let descriptionFile;
|
|
78
|
+
let checksFile;
|
|
79
|
+
let warmFirst = true;
|
|
80
|
+
let dedup = true;
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const raw = argv[i];
|
|
83
|
+
if (raw === '-h' || raw === '--help') {
|
|
84
|
+
help = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (raw === '--print-lenses') {
|
|
88
|
+
printLenses = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw === '--warm-first' || raw === '--no-warm-first') {
|
|
92
|
+
warmFirst = raw === '--warm-first';
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (raw === '--no-dedup') {
|
|
96
|
+
dedup = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const eq = raw.startsWith('--') ? raw.indexOf('=') : -1;
|
|
100
|
+
const name = eq === -1 ? raw : raw.slice(0, eq);
|
|
101
|
+
if (name !== '--base' &&
|
|
102
|
+
name !== '--title' &&
|
|
103
|
+
name !== '--lenses' &&
|
|
104
|
+
name !== '--practices' &&
|
|
105
|
+
name !== '--print-prompt' &&
|
|
106
|
+
name !== '--context-root' &&
|
|
107
|
+
name !== '--description-file' &&
|
|
108
|
+
name !== '--checks-file') {
|
|
109
|
+
return { error: `Unknown argument ${JSON.stringify(raw)}.` };
|
|
110
|
+
}
|
|
111
|
+
let value;
|
|
112
|
+
if (eq !== -1) {
|
|
113
|
+
value = raw.slice(eq + 1);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
value = argv[i + 1];
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
if (value === undefined || value === '') {
|
|
120
|
+
return { error: `${name} needs a value.` };
|
|
121
|
+
}
|
|
122
|
+
if (name === '--base')
|
|
123
|
+
base = value;
|
|
124
|
+
else if (name === '--title')
|
|
125
|
+
title = value;
|
|
126
|
+
else if (name === '--practices')
|
|
127
|
+
practices = value;
|
|
128
|
+
else if (name === '--print-prompt')
|
|
129
|
+
printPrompt = value;
|
|
130
|
+
else if (name === '--context-root')
|
|
131
|
+
contextRoot = value;
|
|
132
|
+
else if (name === '--description-file')
|
|
133
|
+
descriptionFile = value;
|
|
134
|
+
else if (name === '--checks-file')
|
|
135
|
+
checksFile = value;
|
|
136
|
+
else
|
|
137
|
+
lenses = value;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
...(base === undefined ? {} : { base }),
|
|
141
|
+
title,
|
|
142
|
+
...(lenses === undefined ? {} : { lenses }),
|
|
143
|
+
...(practices === undefined ? {} : { practices }),
|
|
144
|
+
...(contextRoot === undefined ? {} : { contextRoot }),
|
|
145
|
+
...(descriptionFile === undefined ? {} : { descriptionFile }),
|
|
146
|
+
...(checksFile === undefined ? {} : { checksFile }),
|
|
147
|
+
warmFirst,
|
|
148
|
+
dedup,
|
|
149
|
+
printLenses,
|
|
150
|
+
...(printPrompt === undefined ? {} : { printPrompt }),
|
|
151
|
+
help,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function present(value) {
|
|
155
|
+
return value === undefined || value.trim() === '' ? undefined : value;
|
|
156
|
+
}
|
|
157
|
+
function defaultKeyFilePath(env) {
|
|
158
|
+
const xdg = present(env['XDG_CONFIG_HOME']);
|
|
159
|
+
if (xdg !== undefined)
|
|
160
|
+
return `${xdg}/verevoir/anthropic.key`;
|
|
161
|
+
const home = present(env['HOME']);
|
|
162
|
+
return home === undefined ? undefined : `${home}/.config/verevoir/anthropic.key`;
|
|
163
|
+
}
|
|
164
|
+
async function readKeyFile(io, path) {
|
|
165
|
+
try {
|
|
166
|
+
return { kind: 'text', text: await io.readFile(path) };
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
const code = cause.code;
|
|
170
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
171
|
+
return { kind: 'absent' };
|
|
172
|
+
// Names the failure, never any bytes: a key must not reach a log.
|
|
173
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
174
|
+
const label = typeof code === 'string' ? code : 'error';
|
|
175
|
+
return { kind: 'unreadable', reason: `${label}: ${message}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The key, in governance's order: ANTHROPIC_API_KEY (non-blank), then
|
|
180
|
+
* ANTHROPIC_API_KEY_FILE, then the conventional default key file; nothing
|
|
181
|
+
* configured selects the claude-cli route. A key file someone configured that
|
|
182
|
+
* is unreadable or blank -- or a NAMED file that is missing -- blocks rather
|
|
183
|
+
* than silently falling back to the keyless route.
|
|
184
|
+
*/
|
|
185
|
+
async function resolveAccount(env, io) {
|
|
186
|
+
const fromEnv = present(env['ANTHROPIC_API_KEY']);
|
|
187
|
+
if (fromEnv !== undefined) {
|
|
188
|
+
return { kind: 'ok', account: { kind: 'key', apiKey: fromEnv }, route: 'env' };
|
|
189
|
+
}
|
|
190
|
+
const named = present(env['ANTHROPIC_API_KEY_FILE']);
|
|
191
|
+
const path = named ?? defaultKeyFilePath(env);
|
|
192
|
+
if (path === undefined) {
|
|
193
|
+
return { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' };
|
|
194
|
+
}
|
|
195
|
+
const read = await readKeyFile(io, path);
|
|
196
|
+
if (read.kind === 'absent') {
|
|
197
|
+
if (named !== undefined) {
|
|
198
|
+
return {
|
|
199
|
+
kind: 'problem',
|
|
200
|
+
problem: `ANTHROPIC_API_KEY_FILE names ${path}, but no file exists there. Check the path, ` +
|
|
201
|
+
'or unset ANTHROPIC_API_KEY_FILE to use the claude-cli route.',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' };
|
|
205
|
+
}
|
|
206
|
+
if (read.kind === 'unreadable') {
|
|
207
|
+
return {
|
|
208
|
+
kind: 'problem',
|
|
209
|
+
problem: `the API key file at ${path} could not be read: ${read.reason}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const key = read.text.trim();
|
|
213
|
+
if (key === '') {
|
|
214
|
+
return {
|
|
215
|
+
kind: 'problem',
|
|
216
|
+
problem: `the API key file at ${path} exists but is blank; fill it in or remove it.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { kind: 'ok', account: { kind: 'key', apiKey: key }, route: 'key-file' };
|
|
220
|
+
}
|
|
221
|
+
function couldNotRun(title, reason, source) {
|
|
222
|
+
return [
|
|
223
|
+
'## Review: Could not run',
|
|
224
|
+
'',
|
|
225
|
+
`**${title}**`,
|
|
226
|
+
'',
|
|
227
|
+
reason,
|
|
228
|
+
'',
|
|
229
|
+
'Nothing was reviewed. This is a red result and not a passing one.',
|
|
230
|
+
...(source === undefined ? [] : ['', '---', '', `Lens set: \`${source}\`.`]),
|
|
231
|
+
].join('\n');
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Run the review. Returns the process exit code; never throws (anything
|
|
235
|
+
* unexpected is reported as could_not_run).
|
|
236
|
+
*/
|
|
237
|
+
export async function main(argv, env, io, deps) {
|
|
238
|
+
const parsed = parseArgs(argv);
|
|
239
|
+
if ('error' in parsed) {
|
|
240
|
+
const reason = `${parsed.error}\n\n${USAGE}`;
|
|
241
|
+
return finish(io, env, 'could_not_run', couldNotRun('this change', reason));
|
|
242
|
+
}
|
|
243
|
+
if (parsed.help) {
|
|
244
|
+
io.stdout(`${USAGE}\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
const title = parsed.title;
|
|
248
|
+
const fail = (reason, source) => finish(io, env, 'could_not_run', couldNotRun(title, reason, source));
|
|
249
|
+
try {
|
|
250
|
+
// 0. Derived lenses (--practices), settled before settings or any diff.
|
|
251
|
+
if (parsed.practices !== undefined && parsed.lenses !== undefined) {
|
|
252
|
+
return await fail('--practices and --lenses cannot be used together; choose one lens source.');
|
|
253
|
+
}
|
|
254
|
+
if (parsed.printLenses && parsed.practices === undefined) {
|
|
255
|
+
return await fail('--print-lenses needs --practices <dir>.');
|
|
256
|
+
}
|
|
257
|
+
let derived;
|
|
258
|
+
let bar;
|
|
259
|
+
if (parsed.practices !== undefined) {
|
|
260
|
+
const dir = parsed.practices;
|
|
261
|
+
// The grading rules (README), read first so its failure has its own reason.
|
|
262
|
+
let readme;
|
|
263
|
+
try {
|
|
264
|
+
readme = await io.readFile(`${dir}/README.md`);
|
|
265
|
+
}
|
|
266
|
+
catch (cause) {
|
|
267
|
+
return await fail(`The practices README at ${dir}/README.md could not be read, so the review has no ` +
|
|
268
|
+
'grading rules (reject / accept / expect): ' +
|
|
269
|
+
`${cause instanceof Error ? cause.message : String(cause)}`);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const names = (await io.readDir(dir)).filter((n) => n.endsWith('.md') && n.toLowerCase() !== 'readme.md');
|
|
273
|
+
const files = await Promise.all(names.map(async (name) => ({ name, text: await io.readFile(`${dir}/${name}`) })));
|
|
274
|
+
derived = deriveLensSet(files);
|
|
275
|
+
}
|
|
276
|
+
catch (cause) {
|
|
277
|
+
return await fail(`The practices under ${dir} could not be read: ` +
|
|
278
|
+
`${cause instanceof Error ? cause.message : String(cause)}`);
|
|
279
|
+
}
|
|
280
|
+
if (derived.untagged.length > 0) {
|
|
281
|
+
io.stderr(`warning: ${String(derived.untagged.length)} practice(s) with no "lens:" tag are ` +
|
|
282
|
+
`not reviewed: ${derived.untagged.join(', ')}\n`);
|
|
283
|
+
}
|
|
284
|
+
for (const [lensId, own] of Object.entries(derived.signals)) {
|
|
285
|
+
if (own.length === 0) {
|
|
286
|
+
io.stderr(`warning: lens ${lensId} has no _Reject:_ / _Expect:_ signal lines, so every ` +
|
|
287
|
+
'blocking finding it raises will be downgraded to a smell.\n');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (derived.groups.length === 0) {
|
|
291
|
+
return await fail(`No practice under ${dir} carries a "lens:" front-matter value, so no lens could be ` +
|
|
292
|
+
'derived. Refusing to review against nothing.');
|
|
293
|
+
}
|
|
294
|
+
const extracted = extractBar(readme);
|
|
295
|
+
if (!extracted.ok) {
|
|
296
|
+
return await fail(`The practices README at ${dir}/README.md cannot supply the grading rules: ` +
|
|
297
|
+
`${extracted.reason}.`);
|
|
298
|
+
}
|
|
299
|
+
bar = extracted.text;
|
|
300
|
+
if (parsed.printLenses) {
|
|
301
|
+
const out = {
|
|
302
|
+
barLength: bar.length,
|
|
303
|
+
lenses: derived.lensSet.lenses.map((lens) => ({
|
|
304
|
+
id: lens.id,
|
|
305
|
+
practices: derived?.groups.find((g) => g.lensId === lens.id)?.practiceIds ?? [],
|
|
306
|
+
focusLength: lens.focus.length,
|
|
307
|
+
})),
|
|
308
|
+
untagged: derived.untagged,
|
|
309
|
+
missingSensor: derived.missingSensor,
|
|
310
|
+
};
|
|
311
|
+
io.stdout(`${JSON.stringify(out, null, 2)}\n`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
if (derived.missingSensor.length > 0) {
|
|
315
|
+
return await fail(`Tagged practice(s) have no Sensor / Look for text, so a lens would check them ` +
|
|
316
|
+
`against nothing: ${derived.missingSensor.join(', ')}.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// 1. Settings, before anything costs money.
|
|
320
|
+
const problems = [];
|
|
321
|
+
// --print-prompt makes no model call, so it needs no key.
|
|
322
|
+
const resolved = parsed.printPrompt
|
|
323
|
+
? { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' }
|
|
324
|
+
: await resolveAccount(env, io);
|
|
325
|
+
if (resolved.kind === 'problem')
|
|
326
|
+
problems.push(resolved.problem);
|
|
327
|
+
const model = present(env['AIGENCY_REVIEW_MODEL']) ?? DEFAULT_MODEL;
|
|
328
|
+
let maxTokens = DEFAULT_MAX_TOKENS;
|
|
329
|
+
const rawMax = present(env['AIGENCY_REVIEW_MAX_TOKENS']);
|
|
330
|
+
if (rawMax !== undefined) {
|
|
331
|
+
const n = Number(rawMax);
|
|
332
|
+
if (!Number.isSafeInteger(n) || n <= 0) {
|
|
333
|
+
problems.push(`AIGENCY_REVIEW_MAX_TOKENS must be a positive whole number, got ${JSON.stringify(rawMax)}`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
maxTokens = n;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (resolved.kind === 'problem' || problems.length > 0) {
|
|
340
|
+
return await fail(`The configuration is present and wrong:\n\n${problems.map((p) => `- ${p}`).join('\n')}`);
|
|
341
|
+
}
|
|
342
|
+
const account = resolved.account;
|
|
343
|
+
const route = resolved.route;
|
|
344
|
+
// 2. The diff.
|
|
345
|
+
let diff;
|
|
346
|
+
if (parsed.base !== undefined) {
|
|
347
|
+
try {
|
|
348
|
+
diff = await deps.git(['diff', '--merge-base', parsed.base, 'HEAD']);
|
|
349
|
+
}
|
|
350
|
+
catch (cause) {
|
|
351
|
+
return await fail(`The diff could not be obtained from \`git diff --merge-base ${parsed.base} HEAD\`: ` +
|
|
352
|
+
`${cause instanceof Error ? cause.message : String(cause)}\n\n` +
|
|
353
|
+
'Check the ref exists and that the checkout has full history (fetch-depth: 0).');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
diff = await io.readStdin();
|
|
358
|
+
}
|
|
359
|
+
if (diff.trim() === '') {
|
|
360
|
+
return await fail('The diff was empty, so there was nothing to review.\n\n' +
|
|
361
|
+
'This is almost always a wiring problem rather than an empty change: a diff computed ' +
|
|
362
|
+
'against the wrong base, or a shallow clone with no merge base. Check how the diff was ' +
|
|
363
|
+
'produced before concluding the change is empty.');
|
|
364
|
+
}
|
|
365
|
+
// 2a. The PR description: --description-file, else a one-line note that
|
|
366
|
+
// none was supplied (so the lenses do not judge an empty description).
|
|
367
|
+
let description = NO_DESCRIPTION_NOTE;
|
|
368
|
+
if (parsed.descriptionFile !== undefined) {
|
|
369
|
+
let text;
|
|
370
|
+
try {
|
|
371
|
+
text = await io.readFile(parsed.descriptionFile);
|
|
372
|
+
}
|
|
373
|
+
catch (cause) {
|
|
374
|
+
return await fail(`The description file ${parsed.descriptionFile} could not be read: ` +
|
|
375
|
+
`${cause instanceof Error ? cause.message : String(cause)}`);
|
|
376
|
+
}
|
|
377
|
+
if (text.trim() === '') {
|
|
378
|
+
return await fail(`The description file ${parsed.descriptionFile} is blank. Omit --description-file ` +
|
|
379
|
+
'if there is no description.');
|
|
380
|
+
}
|
|
381
|
+
description = text.trim();
|
|
382
|
+
}
|
|
383
|
+
// 2a'. CI check results: --checks-file, else a one-line note that none were supplied.
|
|
384
|
+
let checks = NO_CHECKS_NOTE;
|
|
385
|
+
if (parsed.checksFile !== undefined) {
|
|
386
|
+
let text;
|
|
387
|
+
try {
|
|
388
|
+
text = await io.readFile(parsed.checksFile);
|
|
389
|
+
}
|
|
390
|
+
catch (cause) {
|
|
391
|
+
return await fail(`The checks file ${parsed.checksFile} could not be read: ` +
|
|
392
|
+
`${cause instanceof Error ? cause.message : String(cause)}`);
|
|
393
|
+
}
|
|
394
|
+
if (text.trim() === '') {
|
|
395
|
+
return await fail(`The checks file ${parsed.checksFile} is blank. Omit --checks-file if there are no ` +
|
|
396
|
+
'check results.');
|
|
397
|
+
}
|
|
398
|
+
checks = `${text.trim()}\n\n${RED_CI_NOTE}`;
|
|
399
|
+
}
|
|
400
|
+
// 2b. Whole-file context: --context-root, else the current directory in
|
|
401
|
+
// --base mode, else none (said in one line, not an error).
|
|
402
|
+
const contextRoot = parsed.contextRoot ?? (parsed.base !== undefined ? '.' : undefined);
|
|
403
|
+
let fileContext = NO_FILE_CONTEXT_NOTE;
|
|
404
|
+
let references;
|
|
405
|
+
if (contextRoot !== undefined) {
|
|
406
|
+
try {
|
|
407
|
+
const fs = {
|
|
408
|
+
realpath: io.realpath,
|
|
409
|
+
statFile: io.statFile,
|
|
410
|
+
readFile: io.readFile,
|
|
411
|
+
};
|
|
412
|
+
fileContext = await buildFileContext(diff, contextRoot, fs);
|
|
413
|
+
references = await buildReferences(diff, contextRoot, {
|
|
414
|
+
...fs,
|
|
415
|
+
...(io.listDir === undefined ? {} : { listDir: io.listDir }),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (cause) {
|
|
419
|
+
return await fail(`The file context could not be built: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const subject = {
|
|
423
|
+
title,
|
|
424
|
+
description,
|
|
425
|
+
diff,
|
|
426
|
+
fileContext,
|
|
427
|
+
checks,
|
|
428
|
+
...(references === undefined ? {} : { references }),
|
|
429
|
+
};
|
|
430
|
+
// 3. The lens set: --lenses, else AIGENCY_REVIEW_LENSES, else bundled.
|
|
431
|
+
const lensPath = parsed.lenses ?? present(env['AIGENCY_REVIEW_LENSES']);
|
|
432
|
+
const source = derived !== undefined
|
|
433
|
+
? `practices in ${parsed.practices ?? ''} (derived: one lens per lens: value)`
|
|
434
|
+
: (lensPath ?? 'bundled default lenses (aigency-governance review/lenses.json @ de238aa)');
|
|
435
|
+
let lensSet;
|
|
436
|
+
try {
|
|
437
|
+
if (derived !== undefined) {
|
|
438
|
+
lensSet = derived.lensSet;
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const text = lensPath === undefined ? await deps.defaultLenses() : await io.readFile(lensPath);
|
|
442
|
+
lensSet = parseLensSet(text);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (cause) {
|
|
446
|
+
return await fail(`The lens set could not be loaded: ${cause instanceof Error ? cause.message : String(cause)}`, source);
|
|
447
|
+
}
|
|
448
|
+
// 3b. --print-prompt: the exact prompt one lens would be sent; no reviewer call.
|
|
449
|
+
if (parsed.printPrompt !== undefined) {
|
|
450
|
+
const lens = lensSet.lenses.find((l) => l.id === parsed.printPrompt);
|
|
451
|
+
if (lens === undefined) {
|
|
452
|
+
return await fail(`--print-prompt names lens ${JSON.stringify(parsed.printPrompt)}, which is not in the ` +
|
|
453
|
+
`lens set (${lensSet.lenses.map((l) => l.id).join(', ')}).`, source);
|
|
454
|
+
}
|
|
455
|
+
io.stdout(`${renderPrompt(buildPrompt(lens, subject, bar))}\n`);
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
// 4. The panel.
|
|
459
|
+
const calls = [];
|
|
460
|
+
const result = await runReviewNode({
|
|
461
|
+
subject,
|
|
462
|
+
...(bar === undefined ? {} : { practices: bar }),
|
|
463
|
+
lensSet,
|
|
464
|
+
lensSetSource: source,
|
|
465
|
+
account,
|
|
466
|
+
model,
|
|
467
|
+
maxTokens,
|
|
468
|
+
...(deps.reviewer === undefined ? {} : { reviewer: deps.reviewer }),
|
|
469
|
+
warmFirst: parsed.warmFirst,
|
|
470
|
+
dedup: parsed.dedup,
|
|
471
|
+
...(derived === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: { tierSignals: derived.signals, practiceNames: derived.practiceNames }),
|
|
474
|
+
}, (call) => void calls.push(call));
|
|
475
|
+
for (const line of usageLines(calls))
|
|
476
|
+
io.stderr(line);
|
|
477
|
+
const tier = tierLine(result.downgraded);
|
|
478
|
+
if (tier !== undefined)
|
|
479
|
+
io.stderr(`${tier}\n`);
|
|
480
|
+
const footer = `\n\n${renderFooter(model, route, calls, result.downgraded)}`;
|
|
481
|
+
const note = derived !== undefined && derived.untagged.length > 0
|
|
482
|
+
? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +
|
|
483
|
+
`not reviewed: ${derived.untagged.join(', ')}`
|
|
484
|
+
: '';
|
|
485
|
+
return await finish(io, env, result.verdict.outcome, result.report + note + footer, result.exitCode);
|
|
486
|
+
}
|
|
487
|
+
catch (cause) {
|
|
488
|
+
// Defect at the boundary, not a verdict about the change.
|
|
489
|
+
return fail(`Unexpected failure: ${cause instanceof Error ? cause.message : String(cause)}. ` +
|
|
490
|
+
'This is a defect in the runner, not a verdict about the change.');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async function finish(io, env, outcome, report, exitCode = 1) {
|
|
494
|
+
io.stdout(`${report}\n`);
|
|
495
|
+
const summary = present(env['GITHUB_STEP_SUMMARY']);
|
|
496
|
+
if (summary !== undefined) {
|
|
497
|
+
try {
|
|
498
|
+
await io.appendFile(summary, `${report}\n`);
|
|
499
|
+
}
|
|
500
|
+
catch (cause) {
|
|
501
|
+
// The verdict stands; a summary that could not be written is said, not hidden.
|
|
502
|
+
io.stderr(`warning: could not append to GITHUB_STEP_SUMMARY: ${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
io.stderr(`RESULT: ${outcome}\n`);
|
|
506
|
+
return exitCode;
|
|
507
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SourceAccess } from './source-access.js';
|
|
2
|
+
import type { SubjectContext } from './context-types.js';
|
|
3
|
+
export interface ContextBudget {
|
|
4
|
+
/** Hard cap on how many symbols this run returns full bodies for. A
|
|
5
|
+
* diff touching more than this has the remainder named in `omitted`,
|
|
6
|
+
* never silently truncated. */
|
|
7
|
+
readonly maxSymbols?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function assembleSubjectContext(diff: string, source: SourceAccess, budget?: ContextBudget): Promise<SubjectContext>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Assembles CONTEXT about a diff's own touched symbols -- their full
|
|
2
|
+
// bodies, and (name-based, approximate -- @verevoir/context's own
|
|
3
|
+
// documented limit) callers/callees drawn from the rest of the checked-
|
|
4
|
+
// out tree. The reviewer's SUBJECT stays the diff; this is read-only
|
|
5
|
+
// evidence ABOUT it, never a second subject -- see the two-channel split
|
|
6
|
+
// in prompt.ts (`citedContext` vs `subjectContext`) and this library's
|
|
7
|
+
// README.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately NOT called by run-review.ts itself -- that function stays
|
|
10
|
+
// fs-free and unit-testable with a plain object. Every caller with a
|
|
11
|
+
// source root (pump, preflight, eval, the CI Action checkout -- all of
|
|
12
|
+
// them, per the operator's own confirmation) calls this FIRST, renders
|
|
13
|
+
// the result with `renderSubjectContext`, and threads it into
|
|
14
|
+
// `Subject.subjectContext` itself. The "option, off by default" this is
|
|
15
|
+
// meant to sit behind is exactly that: whether a caller calls this
|
|
16
|
+
// function at all. Every existing caller today does not, so every
|
|
17
|
+
// existing caller's prompt output is byte-for-byte unchanged by this PR.
|
|
18
|
+
//
|
|
19
|
+
// SCOPE, deliberately narrow: callers and callees only. No "nearby"
|
|
20
|
+
// definitions, no duplicate-candidate detection -- the latter is PR 4's
|
|
21
|
+
// relational-kinds work (`breaks` / `duplicates` / `deviates`), not this
|
|
22
|
+
// one's.
|
|
23
|
+
import { createContextStore } from '@verevoir/context';
|
|
24
|
+
import { detectLanguage, parseSymbols, edgesForItem } from '@verevoir/context/code';
|
|
25
|
+
import { parseDiffHunks, touchedFiles, rangeTouchedByDiff } from './diff-hunks.js';
|
|
26
|
+
const DEFAULT_MAX_SYMBOLS = 25;
|
|
27
|
+
// A private, fixed (sourceId, version) pair -- this store is created and
|
|
28
|
+
// discarded within one call, never shared across runs, so there is
|
|
29
|
+
// nothing for these two constants to collide with.
|
|
30
|
+
const SOURCE_ID = 'subject';
|
|
31
|
+
const VERSION = '';
|
|
32
|
+
export async function assembleSubjectContext(diff, source, budget = {}) {
|
|
33
|
+
const maxSymbols = budget.maxSymbols ?? DEFAULT_MAX_SYMBOLS;
|
|
34
|
+
const hunks = parseDiffHunks(diff);
|
|
35
|
+
const files = touchedFiles(diff);
|
|
36
|
+
const store = createContextStore();
|
|
37
|
+
const allFiles = await source.listFiles();
|
|
38
|
+
const codeFiles = [];
|
|
39
|
+
for (const path of allFiles) {
|
|
40
|
+
if (detectLanguage(path) === null)
|
|
41
|
+
continue; // never warm non-code files
|
|
42
|
+
const content = await source.readFile(path);
|
|
43
|
+
if (content === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
store.setContent({ sourceId: SOURCE_ID, version: VERSION, itemId: path }, content);
|
|
46
|
+
codeFiles.push(path);
|
|
47
|
+
}
|
|
48
|
+
// One pass over every code file in the tree builds a global
|
|
49
|
+
// callee -> callers index, so resolving callers for a touched symbol
|
|
50
|
+
// never requires a second repo-wide scan per symbol.
|
|
51
|
+
const callerIndex = new Map();
|
|
52
|
+
for (const path of codeFiles) {
|
|
53
|
+
const edges = edgesForItem(store, SOURCE_ID, VERSION, path);
|
|
54
|
+
if (!edges)
|
|
55
|
+
continue;
|
|
56
|
+
for (const call of edges.calls) {
|
|
57
|
+
if (call.from === null)
|
|
58
|
+
continue;
|
|
59
|
+
const set = callerIndex.get(call.to) ?? new Set();
|
|
60
|
+
set.add(call.from);
|
|
61
|
+
callerIndex.set(call.to, set);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const entries = [];
|
|
65
|
+
const omitted = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const path of files) {
|
|
68
|
+
if (detectLanguage(path) === null)
|
|
69
|
+
continue; // e.g. a changed README -- nothing to resolve
|
|
70
|
+
const content = store.getContent({ sourceId: SOURCE_ID, version: VERSION, itemId: path });
|
|
71
|
+
if (content === undefined) {
|
|
72
|
+
omitted.push({
|
|
73
|
+
symbol: path,
|
|
74
|
+
reason: 'file not present at head ref (deleted, or outside the source root)',
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const language = detectLanguage(path);
|
|
79
|
+
let symbols;
|
|
80
|
+
try {
|
|
81
|
+
symbols = parseSymbols(language, content);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
omitted.push({ symbol: path, reason: 'unparseable by tree-sitter' });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const ranges = hunks.get(path) ?? [];
|
|
88
|
+
const touchedSymbols = symbols.filter((s) => rangeTouchedByDiff(hunks, path, s.startLine, s.endLine));
|
|
89
|
+
if (touchedSymbols.length === 0 && ranges.length > 0) {
|
|
90
|
+
omitted.push({
|
|
91
|
+
symbol: path,
|
|
92
|
+
reason: "diff touches this file but no declared symbol's range overlaps a hunk",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const edges = edgesForItem(store, SOURCE_ID, VERSION, path);
|
|
96
|
+
const lines = content.split('\n');
|
|
97
|
+
for (const sym of touchedSymbols) {
|
|
98
|
+
const key = `${path}:${sym.name}`;
|
|
99
|
+
if (seen.has(key))
|
|
100
|
+
continue;
|
|
101
|
+
if (entries.length >= maxSymbols) {
|
|
102
|
+
omitted.push({
|
|
103
|
+
symbol: sym.name,
|
|
104
|
+
reason: `budget exhausted (max ${maxSymbols} symbols per run)`,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
seen.add(key);
|
|
109
|
+
const definition = lines.slice(sym.startLine - 1, sym.endLine).join('\n');
|
|
110
|
+
const callees = edges
|
|
111
|
+
? [...new Set(edges.calls.filter((c) => c.from === sym.name).map((c) => c.to))].sort()
|
|
112
|
+
: [];
|
|
113
|
+
const callers = [...(callerIndex.get(sym.name) ?? new Set())].sort();
|
|
114
|
+
entries.push({ symbol: sym.name, file: path, definition, callers, callees });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { entries, omitted };
|
|
118
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Files larger than this (bytes) are skipped, with the reason named. */
|
|
2
|
+
export declare const MAX_CONTEXT_FILE_BYTES: number;
|
|
3
|
+
/** The one line used when there is no context root (diff read from stdin). */
|
|
4
|
+
export declare const NO_FILE_CONTEXT_NOTE: string;
|
|
5
|
+
export interface ContextFs {
|
|
6
|
+
/** Resolve symlinks. Rejects with an fs-style `code` (ENOENT...) if absent. */
|
|
7
|
+
readonly realpath: (path: string) => Promise<string>;
|
|
8
|
+
readonly statFile: (path: string) => Promise<{
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly isFile: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
readonly readFile: (path: string) => Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
export interface DiffPath {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
/** True when the file is deleted by the change (`+++ /dev/null`). */
|
|
17
|
+
readonly deleted: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The files a unified diff touches, from its `--- ` / `+++ ` header pairs
|
|
21
|
+
* (a `+++` only counts when the line before it is a `--- `). A path git had
|
|
22
|
+
* to quote with escapes is reported with `unreadable: true` via an empty
|
|
23
|
+
* path so the caller can say it was skipped.
|
|
24
|
+
*/
|
|
25
|
+
export declare function diffPaths(diff: string): DiffPath[];
|
|
26
|
+
/** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
|
|
27
|
+
export declare function unsafePathReason(path: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Build the body of the "Changed files in full" section for `diff`, reading
|
|
30
|
+
* from `root`. Throws only when the root itself cannot be resolved; every
|
|
31
|
+
* per-file problem becomes a "skipped" line naming the reason.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildFileContext(diff: string, root: string, fs: ContextFs): Promise<string>;
|