@jphutchins/code-review 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +27 -7
- package/dist/index.js +1320 -186
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/schema/VERSIONING.md +3 -1
- package/schema/findings.schema.json +30 -13
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +69 -44
- package/templates/inline.eta +28 -5
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
|
-
import { readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import { resolve as resolve$1, join } from 'path';
|
|
3
|
+
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
4
|
+
import { resolve as resolve$1, join, dirname } from 'path';
|
|
5
5
|
import { Eta } from 'eta';
|
|
6
6
|
import parseDiff from 'parse-diff';
|
|
7
7
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
@@ -53,43 +53,196 @@ var computeCost = (models, prices, warn = defaultWarn) => {
|
|
|
53
53
|
};
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
// src/patch.ts
|
|
57
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
58
|
+
var hunkOldStart = (line) => {
|
|
59
|
+
const raw = HUNK_HEADER_RE.exec(line)?.[1];
|
|
60
|
+
return raw !== void 0 ? Number(raw) : null;
|
|
61
|
+
};
|
|
62
|
+
var classifyBodyLine = (line) => {
|
|
63
|
+
if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
|
|
64
|
+
if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
|
|
65
|
+
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
var trimmedMiddle = (body) => {
|
|
69
|
+
const first = body.findIndex((l) => l.kind !== "context");
|
|
70
|
+
if (first === -1) return [];
|
|
71
|
+
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
72
|
+
return body.slice(first, last + 1);
|
|
73
|
+
};
|
|
74
|
+
var isContiguousChange = (middle) => {
|
|
75
|
+
if (middle.some((l) => l.kind === "context")) return false;
|
|
76
|
+
const firstAdded = middle.findIndex((l) => l.kind === "added");
|
|
77
|
+
if (firstAdded === -1) return true;
|
|
78
|
+
return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
|
|
79
|
+
};
|
|
80
|
+
var removedRange = (body, oldStart) => body.reduce(
|
|
81
|
+
(acc, line) => line.kind === "added" ? acc : {
|
|
82
|
+
lineNumber: acc.lineNumber + 1,
|
|
83
|
+
firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
|
|
84
|
+
lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
|
|
85
|
+
},
|
|
86
|
+
{ lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
|
|
87
|
+
);
|
|
88
|
+
var drop = (reason) => ({
|
|
89
|
+
kind: "drop",
|
|
90
|
+
reason
|
|
91
|
+
});
|
|
92
|
+
var keep = (reason) => ({
|
|
93
|
+
kind: "keep",
|
|
94
|
+
reason
|
|
95
|
+
});
|
|
96
|
+
var parseHunk = (patch) => {
|
|
97
|
+
const rawLines = patch.split("\n");
|
|
98
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
99
|
+
const headerHits = lines.reduce(
|
|
100
|
+
(acc, line, index) => {
|
|
101
|
+
const oldStart = hunkOldStart(line);
|
|
102
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
103
|
+
},
|
|
104
|
+
[]
|
|
105
|
+
);
|
|
106
|
+
if (headerHits.length !== 1) {
|
|
107
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
108
|
+
}
|
|
109
|
+
const hit = headerHits[0];
|
|
110
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
111
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
112
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
113
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
114
|
+
const body = classified.filter((line) => line !== null);
|
|
115
|
+
return { kind: "ok", oldStart: hit.oldStart, body };
|
|
116
|
+
};
|
|
117
|
+
var validatePatch = (patch, fileLines) => {
|
|
118
|
+
const parsed = parseHunk(patch);
|
|
119
|
+
if (parsed.kind === "drop") return parsed;
|
|
120
|
+
const { oldStart, body } = parsed;
|
|
121
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
122
|
+
const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
|
|
123
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
124
|
+
if (!oldSideMatches) {
|
|
125
|
+
return drop(
|
|
126
|
+
`patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
130
|
+
return drop("change is not a single contiguous block");
|
|
131
|
+
}
|
|
132
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
133
|
+
const addedCount = body.filter((l) => l.kind === "added").length;
|
|
134
|
+
if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
|
|
135
|
+
if (removedCount === 0) {
|
|
136
|
+
return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
|
|
137
|
+
}
|
|
138
|
+
const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
|
|
139
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
140
|
+
return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
|
|
141
|
+
};
|
|
142
|
+
var patchToSuggestion = (patch) => {
|
|
143
|
+
const parsed = parseHunk(patch);
|
|
144
|
+
if (parsed.kind === "drop") return parsed;
|
|
145
|
+
const { body } = parsed;
|
|
146
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
147
|
+
return drop("change is not a single contiguous block");
|
|
148
|
+
}
|
|
149
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
150
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
151
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
152
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
153
|
+
return addedLines.map((l) => l.text).join("\n");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/surface.ts
|
|
157
|
+
var severityEmoji = (s) => {
|
|
158
|
+
switch (s) {
|
|
159
|
+
case "critical":
|
|
160
|
+
return "\u{1F534}";
|
|
161
|
+
case "major":
|
|
162
|
+
return "\u{1F7E0}";
|
|
163
|
+
case "minor":
|
|
164
|
+
return "\u{1F535}";
|
|
165
|
+
case "nit":
|
|
166
|
+
return "\u26AA";
|
|
167
|
+
default:
|
|
168
|
+
return "\u2753";
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
var EMBED_LIMIT = 4e4;
|
|
172
|
+
var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
|
|
173
|
+
var encodeMarker = (document, jsonUrl, limit) => {
|
|
174
|
+
const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
|
|
175
|
+
const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
|
|
176
|
+
return marker ? `${AGENTS_STOP_DIRECTIVE}
|
|
177
|
+
${marker}` : "";
|
|
178
|
+
};
|
|
179
|
+
var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
|
|
180
|
+
var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
|
|
181
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
182
|
+
var projectPatch = (patch) => {
|
|
183
|
+
if (patch === void 0) return { kind: "none" };
|
|
184
|
+
const lowered = patchToSuggestion(patch);
|
|
185
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
186
|
+
};
|
|
187
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
188
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
189
|
+
const sha7 = headSha.slice(0, 7);
|
|
190
|
+
const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
|
|
191
|
+
return marker ? `${marker}
|
|
192
|
+
|
|
193
|
+
${linkLine}` : linkLine;
|
|
194
|
+
};
|
|
195
|
+
|
|
56
196
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
197
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
198
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
199
|
var sanitizeFinding = (f) => ({
|
|
61
200
|
...f,
|
|
62
201
|
title: escapePipes(f.title),
|
|
63
202
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
203
|
+
patchProjection: projectPatch(f.patch)
|
|
65
204
|
});
|
|
205
|
+
var emptySeverityCounts = () => ({
|
|
206
|
+
critical: 0,
|
|
207
|
+
major: 0,
|
|
208
|
+
minor: 0,
|
|
209
|
+
nit: 0
|
|
210
|
+
});
|
|
211
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
212
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
213
|
+
emptySeverityCounts()
|
|
214
|
+
);
|
|
66
215
|
var render = (input) => {
|
|
67
216
|
const eta = new Eta({ autoTrim: false });
|
|
68
217
|
const usageAvailable = input.envelope !== null;
|
|
69
218
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
70
|
-
const
|
|
219
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
220
|
+
const route = input.route ?? input.envelope?.route ?? null;
|
|
221
|
+
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
71
222
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
72
|
-
const findings = input.findings.findings.map(sanitizeFinding);
|
|
73
|
-
const safeFindings = { ...input.findings, findings };
|
|
74
|
-
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
75
223
|
return eta.renderString(input.template, {
|
|
76
|
-
findings:
|
|
224
|
+
findings: input.findings,
|
|
77
225
|
envelope: input.envelope,
|
|
78
226
|
usageAvailable,
|
|
79
227
|
costReport,
|
|
228
|
+
pricesProvided,
|
|
80
229
|
route,
|
|
81
|
-
effort
|
|
230
|
+
effort,
|
|
82
231
|
modelNames,
|
|
83
232
|
testReport: input.testReport ?? null,
|
|
84
233
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
234
|
+
postedAt: input.postedAt ?? "",
|
|
235
|
+
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
236
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
237
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
238
|
+
runUrl: input.runUrl ?? null,
|
|
239
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
240
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
241
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
91
242
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
92
|
-
|
|
243
|
+
// Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
|
|
244
|
+
// real tokens spent, we simply have no rates to price them (SPEC §6.2).
|
|
245
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
93
246
|
formatDuration: (ms) => {
|
|
94
247
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
95
248
|
const s = Math.round(ms / 1e3);
|
|
@@ -107,20 +260,8 @@ var render = (input) => {
|
|
|
107
260
|
return `\u2753 ${v}`;
|
|
108
261
|
}
|
|
109
262
|
},
|
|
110
|
-
severityEmoji
|
|
111
|
-
|
|
112
|
-
case "critical":
|
|
113
|
-
return "\u{1F534}";
|
|
114
|
-
case "major":
|
|
115
|
-
return "\u{1F7E0}";
|
|
116
|
-
case "minor":
|
|
117
|
-
return "\u{1F535}";
|
|
118
|
-
case "nit":
|
|
119
|
-
return "\u26AA";
|
|
120
|
-
default:
|
|
121
|
-
return "\u2753";
|
|
122
|
-
}
|
|
123
|
-
}
|
|
263
|
+
severityEmoji,
|
|
264
|
+
formatConfidence
|
|
124
265
|
});
|
|
125
266
|
};
|
|
126
267
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -177,33 +318,33 @@ var partitionFindings = (findings, index) => {
|
|
|
177
318
|
};
|
|
178
319
|
|
|
179
320
|
// src/inline.ts
|
|
180
|
-
var
|
|
181
|
-
var
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
parts.push(`\`\`\`suggestion
|
|
186
|
-
${safe}
|
|
187
|
-
\`\`\``);
|
|
188
|
-
}
|
|
189
|
-
return parts.join("\n\n");
|
|
190
|
-
};
|
|
191
|
-
var renderCommentBody = (f, eta, template) => {
|
|
192
|
-
return eta.renderString(template, {
|
|
321
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
322
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
323
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
325
|
+
eta.renderString(template, {
|
|
193
326
|
...f,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
327
|
+
patchProjection: projectPatch(f.patch),
|
|
328
|
+
severityEmoji,
|
|
329
|
+
formatConfidence,
|
|
330
|
+
modelsText,
|
|
331
|
+
jsonUrl: jsonUrl ?? null,
|
|
332
|
+
findingsPointer: pointer
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
336
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
198
337
|
const index = indexDiff(diff);
|
|
199
338
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
200
|
-
const eta =
|
|
339
|
+
const eta = new Eta({ autoTrim: false });
|
|
340
|
+
const modelsText = formatModels(models);
|
|
201
341
|
const comments = inDiff.map((f) => {
|
|
342
|
+
const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
|
|
202
343
|
const comment = {
|
|
203
344
|
path: f.path,
|
|
204
345
|
line: f.end_line,
|
|
205
346
|
side: defaultSide(f.side),
|
|
206
|
-
body:
|
|
347
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
207
348
|
};
|
|
208
349
|
if (f.start_line < f.end_line) {
|
|
209
350
|
return {
|
|
@@ -230,6 +371,119 @@ var renderStraysSection = (strays) => {
|
|
|
230
371
|
...items
|
|
231
372
|
].join("\n");
|
|
232
373
|
};
|
|
374
|
+
var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
|
|
375
|
+
var readFileOrNull = (path) => {
|
|
376
|
+
try {
|
|
377
|
+
return readFileSync(path, "utf-8");
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/transcript.ts
|
|
384
|
+
var numField = (rec, key2) => {
|
|
385
|
+
const v = rec[key2];
|
|
386
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
387
|
+
};
|
|
388
|
+
var messageUsage = (entry) => {
|
|
389
|
+
const rec = asRecord(entry);
|
|
390
|
+
if (rec === null || rec["type"] !== "assistant") return null;
|
|
391
|
+
const msg = asRecord(rec["message"]);
|
|
392
|
+
if (msg === null) return null;
|
|
393
|
+
const model = msg["model"];
|
|
394
|
+
const usage = asRecord(msg["usage"]);
|
|
395
|
+
if (typeof model !== "string" || usage === null) return null;
|
|
396
|
+
const id = msg["id"];
|
|
397
|
+
return {
|
|
398
|
+
id: typeof id === "string" ? id : null,
|
|
399
|
+
model,
|
|
400
|
+
input: numField(usage, "input_tokens"),
|
|
401
|
+
output: numField(usage, "output_tokens"),
|
|
402
|
+
cacheRead: numField(usage, "cache_read_input_tokens"),
|
|
403
|
+
cacheWrite: numField(usage, "cache_creation_input_tokens")
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
var tsMsOf = (entry) => {
|
|
407
|
+
const rec = asRecord(entry);
|
|
408
|
+
const ts = rec?.["timestamp"];
|
|
409
|
+
if (typeof ts !== "string") return null;
|
|
410
|
+
const ms = Date.parse(ts);
|
|
411
|
+
return Number.isNaN(ms) ? null : ms;
|
|
412
|
+
};
|
|
413
|
+
var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
414
|
+
var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
415
|
+
try {
|
|
416
|
+
return [JSON.parse(line)];
|
|
417
|
+
} catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
var sumTranscriptUsage = (entries) => {
|
|
422
|
+
const summed = entries.reduce(
|
|
423
|
+
(acc, entry) => {
|
|
424
|
+
const u = messageUsage(entry);
|
|
425
|
+
if (u === null) return acc;
|
|
426
|
+
if (u.id !== null && acc.seen.has(u.id)) return acc;
|
|
427
|
+
if (u.id !== null) acc.seen.add(u.id);
|
|
428
|
+
const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
|
|
429
|
+
acc.totals.set(u.model, {
|
|
430
|
+
input: prev.input + u.input,
|
|
431
|
+
output: prev.output + u.output,
|
|
432
|
+
cacheRead: prev.cacheRead + u.cacheRead,
|
|
433
|
+
cacheWrite: prev.cacheWrite + u.cacheWrite
|
|
434
|
+
});
|
|
435
|
+
return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
|
|
436
|
+
},
|
|
437
|
+
{ totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
|
|
438
|
+
);
|
|
439
|
+
const models = [...summed.totals].map(([model, t4]) => ({
|
|
440
|
+
model,
|
|
441
|
+
input_tokens: t4.input,
|
|
442
|
+
output_tokens: t4.output,
|
|
443
|
+
cache_read_tokens: t4.cacheRead,
|
|
444
|
+
cache_write_tokens: t4.cacheWrite
|
|
445
|
+
}));
|
|
446
|
+
const bounds = entries.reduce(
|
|
447
|
+
(acc, entry) => {
|
|
448
|
+
const ms = tsMsOf(entry);
|
|
449
|
+
if (ms === null) return acc;
|
|
450
|
+
return {
|
|
451
|
+
min: acc.min === null || ms < acc.min ? ms : acc.min,
|
|
452
|
+
max: acc.max === null || ms > acc.max ? ms : acc.max
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
{ min: null, max: null }
|
|
456
|
+
);
|
|
457
|
+
return {
|
|
458
|
+
models,
|
|
459
|
+
turns: summed.turns,
|
|
460
|
+
durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
|
|
461
|
+
firstTsMs: bounds.min,
|
|
462
|
+
lastTsMs: bounds.max
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
var subagentFiles = (mainPath) => {
|
|
466
|
+
try {
|
|
467
|
+
return readdirSync(join(dirname(mainPath), "subagents")).filter((name) => name.endsWith(".jsonl")).map((name) => join(dirname(mainPath), "subagents", name));
|
|
468
|
+
} catch {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
var readTranscriptTree = (mainPath) => {
|
|
473
|
+
const mainText = readFileOrNull(mainPath);
|
|
474
|
+
const mainEntries = mainText === null ? [] : parseJsonl(mainText);
|
|
475
|
+
const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
|
|
476
|
+
const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
|
|
477
|
+
const siblingReads = siblings.flatMap((path) => {
|
|
478
|
+
const text = readFileOrNull(path);
|
|
479
|
+
return text === null ? [] : [{ path, entries: parseJsonl(text) }];
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
|
|
483
|
+
files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
|
|
484
|
+
missing: mainText === null
|
|
485
|
+
};
|
|
486
|
+
};
|
|
233
487
|
var SeverityCodec = t.union([
|
|
234
488
|
t.literal("critical"),
|
|
235
489
|
t.literal("major"),
|
|
@@ -257,14 +511,16 @@ var FindingShape = t.intersection([
|
|
|
257
511
|
end_line: LineNumber,
|
|
258
512
|
severity: SeverityCodec,
|
|
259
513
|
title: t.string,
|
|
260
|
-
|
|
514
|
+
description: t.string,
|
|
515
|
+
reasoning: t.string,
|
|
516
|
+
confidence: Confidence
|
|
261
517
|
}),
|
|
262
518
|
t.partial({
|
|
263
519
|
side: SideCodec,
|
|
264
|
-
suggestion: t.union([t.string, t.null]),
|
|
265
|
-
confidence: Confidence,
|
|
266
520
|
code: t.string,
|
|
267
|
-
code_url: t.string
|
|
521
|
+
code_url: t.string,
|
|
522
|
+
recommendation: t.string,
|
|
523
|
+
patch: t.string
|
|
268
524
|
})
|
|
269
525
|
]);
|
|
270
526
|
var EndGeStart = t.refinement(
|
|
@@ -310,7 +566,9 @@ var ResultEnvelopeCodec = t.intersection([
|
|
|
310
566
|
duration_ms: TokenCount
|
|
311
567
|
}),
|
|
312
568
|
t.partial({
|
|
313
|
-
vendor_cost_usd: t.union([t.number, t.null])
|
|
569
|
+
vendor_cost_usd: t.union([t.number, t.null]),
|
|
570
|
+
route: t.string,
|
|
571
|
+
effort: t.string
|
|
314
572
|
})
|
|
315
573
|
]);
|
|
316
574
|
var ModelPricesCodec = t.type({
|
|
@@ -338,7 +596,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
338
596
|
failures: t.array(TestFailureCodec)
|
|
339
597
|
})
|
|
340
598
|
]);
|
|
341
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
599
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
600
|
+
var noticeFindings = (summary) => ({
|
|
601
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
602
|
+
summary,
|
|
603
|
+
verdict: "comment",
|
|
604
|
+
findings: []
|
|
605
|
+
});
|
|
342
606
|
|
|
343
607
|
// src/validate.ts
|
|
344
608
|
var addFormats = _addFormats;
|
|
@@ -374,10 +638,221 @@ var unsafeUnwrap = (decoded) => {
|
|
|
374
638
|
if (decoded._tag === "Right") return decoded.right;
|
|
375
639
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
376
640
|
};
|
|
641
|
+
|
|
642
|
+
// src/stop-gate.ts
|
|
643
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
644
|
+
switch (state.kind) {
|
|
645
|
+
case "missing":
|
|
646
|
+
return `${draftPath} does not exist yet`;
|
|
647
|
+
case "unreadable":
|
|
648
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
649
|
+
case "invalid":
|
|
650
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
651
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
655
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
656
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
657
|
+
return {
|
|
658
|
+
kind: "block",
|
|
659
|
+
reason: [
|
|
660
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
661
|
+
`The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
|
|
662
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
|
|
663
|
+
].join("\n")
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
667
|
+
let raw;
|
|
668
|
+
try {
|
|
669
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
672
|
+
return { kind: "missing" };
|
|
673
|
+
}
|
|
674
|
+
return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
|
|
675
|
+
}
|
|
676
|
+
let parsed;
|
|
677
|
+
try {
|
|
678
|
+
parsed = JSON.parse(raw);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
return {
|
|
681
|
+
kind: "invalid",
|
|
682
|
+
errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
let schemaPath;
|
|
686
|
+
try {
|
|
687
|
+
schemaPath = resolveSchema(parsed);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
693
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
694
|
+
} catch (err) {
|
|
695
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
var readNudges = (counterPath) => {
|
|
699
|
+
try {
|
|
700
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
701
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
702
|
+
} catch {
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
var bumpNudges = (counterPath, current) => {
|
|
707
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
708
|
+
`);
|
|
709
|
+
};
|
|
710
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
711
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
712
|
+
"code-review stop-gate --draft",
|
|
713
|
+
shellQuote(draftPath),
|
|
714
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
715
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
716
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
717
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
718
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
719
|
+
].join(" ");
|
|
720
|
+
var stopHookSettings = (command) => ({
|
|
721
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// src/budget.ts
|
|
725
|
+
var DEFAULT_RESERVE = { frac: 0.15, flatUsd: 0.02, flatMs: 12e4 };
|
|
726
|
+
var SOFT_MULTIPLE = 2;
|
|
727
|
+
var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
|
|
728
|
+
var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
|
|
729
|
+
var axisSeverity = (a, frac) => {
|
|
730
|
+
const hardReserve = Math.max(a.flat, frac * a.limit);
|
|
731
|
+
const remaining = a.limit - a.used;
|
|
732
|
+
if (remaining <= hardReserve) return 2;
|
|
733
|
+
if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
|
|
734
|
+
return 0;
|
|
735
|
+
};
|
|
736
|
+
var decideBudget = (i) => {
|
|
737
|
+
const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve.frac)), 0);
|
|
738
|
+
return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
|
|
739
|
+
};
|
|
740
|
+
var pct = (n) => `${String(Math.round(n * 100))}%`;
|
|
741
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
742
|
+
var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
|
|
743
|
+
var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
|
|
744
|
+
var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
|
|
745
|
+
var directive = (phase, draftPath) => phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Write your COMPLETE findings to ${draftPath} and run \`code-review validate ${draftPath}\` until it passes. Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then validate \u2014 you may run out of budget before you finish otherwise.`;
|
|
746
|
+
var budgetMessage = (i, phase, draftPath) => {
|
|
747
|
+
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
748
|
+
return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
|
|
749
|
+
};
|
|
750
|
+
var invokesCodeReviewValidate = (toolInput) => {
|
|
751
|
+
const cmd = asRecord(toolInput)?.["command"];
|
|
752
|
+
return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
|
|
753
|
+
};
|
|
754
|
+
var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
|
|
755
|
+
var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
|
|
756
|
+
var blockedDuringConvergence = (toolName, toolInput) => {
|
|
757
|
+
if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
|
|
758
|
+
if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
|
|
759
|
+
return false;
|
|
760
|
+
};
|
|
761
|
+
var evaluateBudgetHook = (input, params) => {
|
|
762
|
+
const rec = asRecord(input);
|
|
763
|
+
const inputs = {
|
|
764
|
+
spentUsd: params.spentUsd,
|
|
765
|
+
budgetUsd: params.budgetUsd,
|
|
766
|
+
elapsedMs: params.elapsedMs,
|
|
767
|
+
wallMs: params.wallMs,
|
|
768
|
+
reserve: params.reserve
|
|
769
|
+
};
|
|
770
|
+
const phase = decideBudget(inputs);
|
|
771
|
+
switch (rec?.["hook_event_name"]) {
|
|
772
|
+
case "PostToolBatch":
|
|
773
|
+
return phase.kind === "ok" ? {} : {
|
|
774
|
+
hookSpecificOutput: {
|
|
775
|
+
hookEventName: "PostToolBatch",
|
|
776
|
+
additionalContext: budgetMessage(inputs, phase, params.draftPath)
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
case "PreToolUse": {
|
|
780
|
+
if (phase.kind !== "hard") return {};
|
|
781
|
+
const toolName = rec["tool_name"];
|
|
782
|
+
if (typeof toolName === "string" && blockedDuringConvergence(toolName, rec["tool_input"]))
|
|
783
|
+
return {
|
|
784
|
+
hookSpecificOutput: {
|
|
785
|
+
hookEventName: "PreToolUse",
|
|
786
|
+
permissionDecision: "deny",
|
|
787
|
+
permissionDecisionReason: budgetMessage(inputs, phase, params.draftPath)
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
return {};
|
|
791
|
+
}
|
|
792
|
+
default:
|
|
793
|
+
return {};
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
var parseWallMs = (raw) => {
|
|
797
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
|
|
798
|
+
if (m === null) return null;
|
|
799
|
+
const [, num = "", unit = "s"] = m;
|
|
800
|
+
const n = Number.parseFloat(num);
|
|
801
|
+
if (!Number.isFinite(n)) return null;
|
|
802
|
+
switch (unit) {
|
|
803
|
+
case "ms":
|
|
804
|
+
return n;
|
|
805
|
+
case "s":
|
|
806
|
+
return n * 1e3;
|
|
807
|
+
case "m":
|
|
808
|
+
return n * 6e4;
|
|
809
|
+
default:
|
|
810
|
+
return n * 36e5;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
var parseFraction = (raw, fallback) => {
|
|
814
|
+
if (raw === void 0) return fallback;
|
|
815
|
+
const n = Number.parseFloat(raw);
|
|
816
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
|
|
817
|
+
};
|
|
818
|
+
var budgetHookCommand = (draftPath, opts) => [
|
|
819
|
+
"code-review budget-hook --draft",
|
|
820
|
+
shellQuote(draftPath),
|
|
821
|
+
...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
|
|
822
|
+
...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
|
|
823
|
+
...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
|
|
824
|
+
...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
|
|
825
|
+
...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
|
|
826
|
+
...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
|
|
827
|
+
].join(" ");
|
|
828
|
+
|
|
829
|
+
// src/format.ts
|
|
830
|
+
var FENCE_RE = /^\s*```/;
|
|
831
|
+
var scanLine = (state, line) => {
|
|
832
|
+
if (FENCE_RE.test(line)) {
|
|
833
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
834
|
+
}
|
|
835
|
+
if (state.inFence) {
|
|
836
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
837
|
+
}
|
|
838
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
839
|
+
if (trimmed !== "") {
|
|
840
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
841
|
+
}
|
|
842
|
+
const blankRun = state.blankRun + 1;
|
|
843
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
844
|
+
};
|
|
845
|
+
var formatMarkdown = (md) => {
|
|
846
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
847
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
848
|
+
`;
|
|
849
|
+
};
|
|
850
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
851
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
377
852
|
var identity = (decoded) => decoded;
|
|
378
853
|
var findingsTable = [
|
|
379
854
|
{
|
|
380
|
-
minor: "0.
|
|
855
|
+
minor: "0.4",
|
|
381
856
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
382
857
|
schemaFile: "findings.schema.json",
|
|
383
858
|
codec: FindingsCodec,
|
|
@@ -502,7 +977,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
502
977
|
// src/post.ts
|
|
503
978
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
504
979
|
var MAX_SUGGESTION_LINES = 10;
|
|
505
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
506
980
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
507
981
|
var checkLongSuggestions = (comments) => {
|
|
508
982
|
const longFiles = [];
|
|
@@ -522,13 +996,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
522
996
|
});
|
|
523
997
|
return { comments: adjusted, longFiles };
|
|
524
998
|
};
|
|
525
|
-
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
526
|
-
var noticeFindings = (message) => ({
|
|
527
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
528
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
529
|
-
verdict: "comment",
|
|
530
|
-
findings: []
|
|
531
|
-
});
|
|
532
999
|
var loadFindings = (path) => {
|
|
533
1000
|
let raw;
|
|
534
1001
|
try {
|
|
@@ -590,9 +1057,18 @@ var loadTestReport = (path) => {
|
|
|
590
1057
|
}
|
|
591
1058
|
return decoded.right;
|
|
592
1059
|
};
|
|
593
|
-
var
|
|
1060
|
+
var parseHtmlUrl = (raw) => {
|
|
1061
|
+
try {
|
|
1062
|
+
const parsed = JSON.parse(raw);
|
|
1063
|
+
return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return void 0;
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
|
|
1069
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
594
1070
|
const body = JSON.stringify({
|
|
595
|
-
body:
|
|
1071
|
+
body: pointer,
|
|
596
1072
|
commit_id: headSha,
|
|
597
1073
|
event: "COMMENT",
|
|
598
1074
|
comments: comments.map((c) => ({
|
|
@@ -600,10 +1076,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
|
|
|
600
1076
|
line: c.line,
|
|
601
1077
|
side: c.side,
|
|
602
1078
|
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
603
|
-
body: c.body
|
|
1079
|
+
body: formatMarkdown(c.body)
|
|
604
1080
|
}))
|
|
605
1081
|
});
|
|
606
|
-
await ghApi(
|
|
1082
|
+
const stdout = await ghApi(
|
|
1083
|
+
[`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
|
|
1084
|
+
body
|
|
1085
|
+
);
|
|
1086
|
+
return parseHtmlUrl(stdout);
|
|
607
1087
|
};
|
|
608
1088
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
609
1089
|
const stdout = await ghApi(
|
|
@@ -623,33 +1103,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
623
1103
|
const parsed = JSON.parse(last);
|
|
624
1104
|
return { id: parsed.id, body: parsed.body };
|
|
625
1105
|
};
|
|
1106
|
+
var parseCommentRef = (raw) => {
|
|
1107
|
+
try {
|
|
1108
|
+
const parsed = JSON.parse(raw);
|
|
1109
|
+
return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
|
|
1110
|
+
} catch {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
626
1114
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
627
|
-
await ghApi(
|
|
1115
|
+
const stdout = await ghApi(
|
|
628
1116
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
629
1117
|
JSON.stringify({ body })
|
|
630
1118
|
);
|
|
1119
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
1120
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
631
1121
|
};
|
|
632
1122
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
633
|
-
await ghApi(
|
|
1123
|
+
const stdout = await ghApi(
|
|
634
1124
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
635
1125
|
JSON.stringify({ body })
|
|
636
1126
|
);
|
|
1127
|
+
return parseCommentRef(stdout);
|
|
637
1128
|
};
|
|
638
1129
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
639
1130
|
if (existing !== null) {
|
|
640
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
1131
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
641
1132
|
process.stderr.write(
|
|
642
1133
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
643
1134
|
`
|
|
644
1135
|
);
|
|
645
|
-
|
|
646
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
647
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
648
|
-
`);
|
|
1136
|
+
return { id: existing.id, url: patched?.html_url };
|
|
649
1137
|
}
|
|
1138
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
1139
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
1140
|
+
`);
|
|
1141
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
650
1142
|
};
|
|
651
1143
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
652
|
-
var
|
|
1144
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
653
1145
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
654
1146
|
let reviews;
|
|
655
1147
|
try {
|
|
@@ -658,10 +1150,12 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
658
1150
|
return [];
|
|
659
1151
|
}
|
|
660
1152
|
if (!Array.isArray(reviews)) return [];
|
|
661
|
-
return reviews.filter(
|
|
1153
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
|
|
1154
|
+
id: r.id,
|
|
1155
|
+
commitId: typeof r.commit_id === "string" ? r.commit_id : ""
|
|
1156
|
+
}));
|
|
662
1157
|
};
|
|
663
|
-
var
|
|
664
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
1158
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
665
1159
|
for (const id of ids) {
|
|
666
1160
|
try {
|
|
667
1161
|
await ghApi(
|
|
@@ -682,6 +1176,84 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
682
1176
|
}
|
|
683
1177
|
}
|
|
684
1178
|
};
|
|
1179
|
+
var REVIEW_THREAD_COMMENTS_QUERY = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage}nodes{comments(first:100){nodes{id isMinimized author{login} originalCommit{oid}}}}}}}}";
|
|
1180
|
+
var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
|
|
1181
|
+
var supersededCommentId = (c, headSha, logins) => {
|
|
1182
|
+
if (typeof c !== "object" || c === null) return null;
|
|
1183
|
+
const o = c;
|
|
1184
|
+
const login = o.author?.login;
|
|
1185
|
+
const oid = o.originalCommit?.oid;
|
|
1186
|
+
return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) && typeof oid === "string" && oid !== headSha ? o.id : null;
|
|
1187
|
+
};
|
|
1188
|
+
var supersededBotCommentIds = (raw, headSha, botLogin) => {
|
|
1189
|
+
let parsed;
|
|
1190
|
+
try {
|
|
1191
|
+
parsed = JSON.parse(raw);
|
|
1192
|
+
} catch {
|
|
1193
|
+
return { ids: [], truncated: false };
|
|
1194
|
+
}
|
|
1195
|
+
const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
1196
|
+
const truncated = conn?.pageInfo?.hasNextPage === true;
|
|
1197
|
+
const nodes = conn?.nodes;
|
|
1198
|
+
if (!Array.isArray(nodes)) return { ids: [], truncated };
|
|
1199
|
+
const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
|
|
1200
|
+
const ids = nodes.flatMap((t4) => {
|
|
1201
|
+
const cnodes = t4.comments?.nodes;
|
|
1202
|
+
return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
|
|
1203
|
+
});
|
|
1204
|
+
return { ids, truncated };
|
|
1205
|
+
};
|
|
1206
|
+
var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
|
|
1207
|
+
const slash = repo.indexOf("/");
|
|
1208
|
+
if (slash <= 0) return;
|
|
1209
|
+
const owner = repo.slice(0, slash);
|
|
1210
|
+
const name = repo.slice(slash + 1);
|
|
1211
|
+
let raw;
|
|
1212
|
+
try {
|
|
1213
|
+
raw = await ghApi([
|
|
1214
|
+
"graphql",
|
|
1215
|
+
"-f",
|
|
1216
|
+
`query=${REVIEW_THREAD_COMMENTS_QUERY}`,
|
|
1217
|
+
"-f",
|
|
1218
|
+
`owner=${owner}`,
|
|
1219
|
+
"-f",
|
|
1220
|
+
`name=${name}`,
|
|
1221
|
+
"-F",
|
|
1222
|
+
`pr=${String(prNumber)}`
|
|
1223
|
+
]);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
process.stderr.write(
|
|
1226
|
+
`Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
|
|
1227
|
+
`
|
|
1228
|
+
);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
|
|
1232
|
+
if (truncated) {
|
|
1233
|
+
process.stderr.write(
|
|
1234
|
+
`Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
|
|
1235
|
+
`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
let minimized = 0;
|
|
1239
|
+
for (const id of ids) {
|
|
1240
|
+
try {
|
|
1241
|
+
await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
|
|
1242
|
+
minimized += 1;
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
process.stderr.write(
|
|
1245
|
+
`Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
|
|
1246
|
+
`
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (minimized > 0) {
|
|
1251
|
+
process.stderr.write(
|
|
1252
|
+
`Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
|
|
1253
|
+
`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
685
1257
|
var post = async (input, ghApi = runGhApi) => {
|
|
686
1258
|
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
687
1259
|
const resolution = resolvePr(candidates, input.headBranch);
|
|
@@ -706,24 +1278,28 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
706
1278
|
DEFAULT_MARKER,
|
|
707
1279
|
ghApi
|
|
708
1280
|
);
|
|
709
|
-
const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
|
|
710
|
-
const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
|
|
711
1281
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
712
1282
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
713
1283
|
if (decodedPrices._tag === "Left") {
|
|
714
1284
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
715
1285
|
}
|
|
716
1286
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
717
|
-
const inlineTemplate =
|
|
718
|
-
const renderNotice = (message) =>
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1287
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
1288
|
+
const renderNotice = (message) => formatMarkdown(
|
|
1289
|
+
render({
|
|
1290
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
1291
|
+
envelope: null,
|
|
1292
|
+
prices: decodedPrices.right,
|
|
1293
|
+
pricesProvided: input.pricesProvided,
|
|
1294
|
+
template,
|
|
1295
|
+
route: input.route,
|
|
1296
|
+
reviewedSha: input.headSha,
|
|
1297
|
+
effort: input.effort,
|
|
1298
|
+
runUrl: input.runUrl,
|
|
1299
|
+
jsonUrl: input.jsonUrl,
|
|
1300
|
+
postedAt: input.postedAt
|
|
1301
|
+
})
|
|
1302
|
+
);
|
|
727
1303
|
if (isEmptyDiff(diff)) {
|
|
728
1304
|
await upsertSticky(
|
|
729
1305
|
input.repo,
|
|
@@ -749,27 +1325,36 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
749
1325
|
const envelope = loadEnvelope(input.envelopePath);
|
|
750
1326
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
751
1327
|
if (envelope === null) {
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1328
|
+
const body = formatMarkdown(
|
|
1329
|
+
render({
|
|
1330
|
+
findings,
|
|
1331
|
+
envelope: null,
|
|
1332
|
+
prices: decodedPrices.right,
|
|
1333
|
+
pricesProvided: input.pricesProvided,
|
|
1334
|
+
template,
|
|
1335
|
+
route: input.route,
|
|
1336
|
+
reviewedSha: input.headSha,
|
|
1337
|
+
effort: input.effort,
|
|
1338
|
+
testReport,
|
|
1339
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
1340
|
+
runUrl: input.runUrl,
|
|
1341
|
+
jsonUrl: input.jsonUrl,
|
|
1342
|
+
postedAt: input.postedAt
|
|
1343
|
+
})
|
|
1344
|
+
);
|
|
1345
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
763
1346
|
process.stderr.write(
|
|
764
1347
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
765
1348
|
);
|
|
766
1349
|
process.exit(0);
|
|
767
1350
|
}
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1351
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
1352
|
+
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
1353
|
+
inlineTemplate,
|
|
1354
|
+
models: envelope.models.map((m) => m.model),
|
|
1355
|
+
findings,
|
|
1356
|
+
jsonUrl: input.jsonUrl
|
|
1357
|
+
});
|
|
773
1358
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
774
1359
|
for (const wf of longFiles) {
|
|
775
1360
|
process.stderr.write(
|
|
@@ -777,43 +1362,87 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
777
1362
|
`
|
|
778
1363
|
);
|
|
779
1364
|
}
|
|
780
|
-
|
|
1365
|
+
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
1366
|
+
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
1367
|
+
const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
1368
|
+
const commonRenderInput = {
|
|
781
1369
|
findings,
|
|
782
1370
|
envelope,
|
|
783
1371
|
prices: decodedPrices.right,
|
|
1372
|
+
pricesProvided: input.pricesProvided,
|
|
784
1373
|
template,
|
|
785
1374
|
route: input.route,
|
|
786
1375
|
reviewedSha: input.headSha,
|
|
787
1376
|
effort: input.effort,
|
|
788
|
-
testReport
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1377
|
+
testReport,
|
|
1378
|
+
severityCounts: computeSeverityCounts(findings.findings),
|
|
1379
|
+
strays,
|
|
1380
|
+
runUrl: input.runUrl,
|
|
1381
|
+
jsonUrl: input.jsonUrl,
|
|
1382
|
+
findingsPointer: findingsMarker,
|
|
1383
|
+
postedAt: input.postedAt
|
|
1384
|
+
};
|
|
1385
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
794
1386
|
|
|
795
1387
|
---
|
|
796
1388
|
|
|
797
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
798
|
-
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1389
|
+
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
|
|
1390
|
+
` : "";
|
|
1391
|
+
const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
1392
|
+
const stickyRef = await upsertSticky(
|
|
1393
|
+
input.repo,
|
|
1394
|
+
prNumber,
|
|
1395
|
+
existingSticky,
|
|
1396
|
+
renderBody(initialDisposition),
|
|
1397
|
+
ghApi
|
|
1398
|
+
);
|
|
1399
|
+
if (comments.length === 0) return;
|
|
1400
|
+
if (alreadyReviewedThisSha) {
|
|
805
1401
|
process.stderr.write(
|
|
806
|
-
`
|
|
1402
|
+
`A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new inline review
|
|
807
1403
|
`
|
|
808
1404
|
);
|
|
809
1405
|
return;
|
|
810
1406
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1407
|
+
const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
|
|
1408
|
+
if (stalePriorReviewIds.length > 0) {
|
|
1409
|
+
await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
|
|
1410
|
+
}
|
|
1411
|
+
const reviewUrl = await postInlineReview(
|
|
1412
|
+
input.repo,
|
|
1413
|
+
prNumber,
|
|
1414
|
+
input.headSha,
|
|
1415
|
+
comments,
|
|
1416
|
+
stickyRef?.url,
|
|
1417
|
+
findingsMarker,
|
|
1418
|
+
ghApi
|
|
1419
|
+
);
|
|
1420
|
+
process.stderr.write(
|
|
1421
|
+
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
815
1422
|
`
|
|
816
|
-
|
|
1423
|
+
);
|
|
1424
|
+
await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
|
|
1425
|
+
if (stickyRef !== null) {
|
|
1426
|
+
const confirmedDisposition = {
|
|
1427
|
+
kind: "posted",
|
|
1428
|
+
count: comments.length,
|
|
1429
|
+
sha: input.headSha
|
|
1430
|
+
};
|
|
1431
|
+
try {
|
|
1432
|
+
await patchComment(
|
|
1433
|
+
input.repo,
|
|
1434
|
+
stickyRef.id,
|
|
1435
|
+
renderBody(confirmedDisposition, reviewUrl),
|
|
1436
|
+
ghApi
|
|
1437
|
+
);
|
|
1438
|
+
process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
|
|
1439
|
+
`);
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
process.stderr.write(
|
|
1442
|
+
`Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
|
|
1443
|
+
`
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
817
1446
|
}
|
|
818
1447
|
};
|
|
819
1448
|
var renderOutputs = (result) => {
|
|
@@ -952,6 +1581,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
952
1581
|
diffSize: Buffer.byteLength(diff, "utf8")
|
|
953
1582
|
};
|
|
954
1583
|
};
|
|
1584
|
+
|
|
1585
|
+
// src/extract.ts
|
|
955
1586
|
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
956
1587
|
var parseNativeForExtraction = (raw) => ({
|
|
957
1588
|
result: fieldOf(raw, "result"),
|
|
@@ -1011,13 +1642,6 @@ var tryParseJson = (text) => {
|
|
|
1011
1642
|
return { ok: false };
|
|
1012
1643
|
}
|
|
1013
1644
|
};
|
|
1014
|
-
var readFileOrNull = (path) => {
|
|
1015
|
-
try {
|
|
1016
|
-
return readFileSync(path, "utf-8");
|
|
1017
|
-
} catch {
|
|
1018
|
-
return null;
|
|
1019
|
-
}
|
|
1020
|
-
};
|
|
1021
1645
|
var candidateFromJsonText = (kind, text) => {
|
|
1022
1646
|
if (text === null) return null;
|
|
1023
1647
|
const parsed = tryParseJson(text);
|
|
@@ -1025,7 +1649,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1025
1649
|
};
|
|
1026
1650
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1027
1651
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1028
|
-
var
|
|
1652
|
+
var scanLine2 = (state, line) => {
|
|
1029
1653
|
if (state.openLength === null) {
|
|
1030
1654
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1031
1655
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1034,7 +1658,27 @@ var scanLine = (state, line) => {
|
|
|
1034
1658
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1035
1659
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1036
1660
|
};
|
|
1037
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
1661
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1662
|
+
var ladderFailureDiagnostics = (input) => {
|
|
1663
|
+
const native = parseNativeForExtraction(input.native);
|
|
1664
|
+
const preview = (s) => {
|
|
1665
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
1666
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
|
|
1667
|
+
};
|
|
1668
|
+
const lines = [];
|
|
1669
|
+
if (input.kind === "findings") {
|
|
1670
|
+
lines.push(
|
|
1671
|
+
input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
lines.push(
|
|
1675
|
+
isNullish(native.structuredOutput) ? "structured_output rung: absent (null) \u2014 the CLI's --json-schema likely did not enforce" : "structured_output rung: present but did not validate against the schema"
|
|
1676
|
+
);
|
|
1677
|
+
lines.push(
|
|
1678
|
+
typeof native.result === "string" ? `result rung: ${String(native.result.length)} chars, ${String(scanFencedBlocks(native.result).length)} fenced JSON block(s), none validated; preview: ${preview(native.result)}` : "result rung: absent or not a string"
|
|
1679
|
+
);
|
|
1680
|
+
return lines.join("\n");
|
|
1681
|
+
};
|
|
1038
1682
|
var describeLadderFailure = (outcome) => {
|
|
1039
1683
|
switch (outcome.kind) {
|
|
1040
1684
|
case "error-envelope":
|
|
@@ -1117,36 +1761,74 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1117
1761
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1118
1762
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1119
1763
|
}));
|
|
1120
|
-
var
|
|
1121
|
-
const
|
|
1122
|
-
if (
|
|
1123
|
-
return
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1764
|
+
var findingsOutcome = (native, agentFilePath) => {
|
|
1765
|
+
const ladder = extractStructured({ kind: "findings", native, agentFilePath });
|
|
1766
|
+
if (ladder.kind !== "ok")
|
|
1767
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
1768
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
1769
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
1770
|
+
kind: "telemetry-only",
|
|
1771
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
1772
|
+
};
|
|
1773
|
+
};
|
|
1774
|
+
var withMeta = (base, meta) => ({
|
|
1775
|
+
...base,
|
|
1776
|
+
...meta.route ? { route: meta.route } : {},
|
|
1777
|
+
...meta.effort ? { effort: meta.effort } : {}
|
|
1778
|
+
});
|
|
1779
|
+
var nativeTelemetry = (native, meta) => withMeta(
|
|
1780
|
+
{
|
|
1129
1781
|
models: mapModelUsage(native.modelUsage),
|
|
1130
1782
|
turns: native.num_turns,
|
|
1131
1783
|
duration_ms: native.duration_ms,
|
|
1132
1784
|
vendor_cost_usd: native.total_cost_usd ?? null
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1785
|
+
},
|
|
1786
|
+
meta
|
|
1787
|
+
);
|
|
1788
|
+
var absentTelemetry = (meta) => withMeta({ models: [], turns: 0, duration_ms: 0, vendor_cost_usd: null }, meta);
|
|
1789
|
+
var buildEnvelope = (telemetry, native, agentFilePath) => {
|
|
1790
|
+
const outcome = findingsOutcome(native, agentFilePath);
|
|
1791
|
+
switch (outcome.kind) {
|
|
1792
|
+
case "ok":
|
|
1793
|
+
return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
|
|
1794
|
+
case "telemetry-only":
|
|
1795
|
+
return {
|
|
1796
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1797
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
1798
|
+
|
|
1799
|
+
${outcome.reason}`),
|
|
1800
|
+
...telemetry
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1136
1803
|
};
|
|
1137
|
-
var adapt = (adapterName, native, agentFilePath) => {
|
|
1804
|
+
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1138
1805
|
switch (adapterName) {
|
|
1139
1806
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1140
1807
|
case "claude-code": {
|
|
1808
|
+
if (native === void 0 || native === null)
|
|
1809
|
+
return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
|
|
1141
1810
|
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1142
|
-
if (decoded._tag === "Left")
|
|
1811
|
+
if (decoded._tag === "Left")
|
|
1143
1812
|
return left("native envelope does not match the Claude Code output shape");
|
|
1144
|
-
|
|
1145
|
-
return adaptClaudeCode(decoded.right, agentFilePath);
|
|
1813
|
+
return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
|
|
1146
1814
|
}
|
|
1147
1815
|
}
|
|
1148
1816
|
};
|
|
1149
1817
|
|
|
1818
|
+
// src/settings.ts
|
|
1819
|
+
var composeReviewSettings = (opts) => {
|
|
1820
|
+
const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
|
|
1821
|
+
return {
|
|
1822
|
+
hooks: {
|
|
1823
|
+
Stop: [
|
|
1824
|
+
{ hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
|
|
1825
|
+
],
|
|
1826
|
+
PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
|
|
1827
|
+
PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1150
1832
|
// src/index.ts
|
|
1151
1833
|
var readJSON = (path) => {
|
|
1152
1834
|
try {
|
|
@@ -1161,6 +1843,54 @@ var fail = (msg) => {
|
|
|
1161
1843
|
`);
|
|
1162
1844
|
process.exit(1);
|
|
1163
1845
|
};
|
|
1846
|
+
var readJSONOrAbsent = (path) => {
|
|
1847
|
+
const read = (() => {
|
|
1848
|
+
try {
|
|
1849
|
+
return { text: readFileSync(resolve$1(path), "utf-8") };
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1852
|
+
}
|
|
1853
|
+
})();
|
|
1854
|
+
if ("error" in read) {
|
|
1855
|
+
process.stderr.write(
|
|
1856
|
+
`code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
|
|
1857
|
+
`
|
|
1858
|
+
);
|
|
1859
|
+
return void 0;
|
|
1860
|
+
}
|
|
1861
|
+
if (read.text.trim() === "") {
|
|
1862
|
+
process.stderr.write(
|
|
1863
|
+
`code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
|
|
1864
|
+
`
|
|
1865
|
+
);
|
|
1866
|
+
return void 0;
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
return JSON.parse(read.text);
|
|
1870
|
+
} catch (err) {
|
|
1871
|
+
process.stderr.write(
|
|
1872
|
+
`code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
|
|
1873
|
+
`
|
|
1874
|
+
);
|
|
1875
|
+
return void 0;
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
var readStdinJSON = () => {
|
|
1879
|
+
if (process.stdin.isTTY) return null;
|
|
1880
|
+
const raw = (() => {
|
|
1881
|
+
try {
|
|
1882
|
+
return readFileSync(0, "utf-8");
|
|
1883
|
+
} catch {
|
|
1884
|
+
return "";
|
|
1885
|
+
}
|
|
1886
|
+
})();
|
|
1887
|
+
if (raw.trim() === "") return null;
|
|
1888
|
+
try {
|
|
1889
|
+
return JSON.parse(raw);
|
|
1890
|
+
} catch {
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1164
1894
|
var decode = (either, label) => {
|
|
1165
1895
|
try {
|
|
1166
1896
|
return unsafeUnwrap(either);
|
|
@@ -1181,12 +1911,13 @@ var unwrapAdapt = (either) => {
|
|
|
1181
1911
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1182
1912
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1183
1913
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1184
|
-
var
|
|
1185
|
-
|
|
1914
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
1915
|
+
var resolvePrices = (pricesArg) => {
|
|
1916
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1186
1917
|
process.stderr.write(
|
|
1187
|
-
"code-review: no --prices given \u2014
|
|
1918
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1188
1919
|
);
|
|
1189
|
-
return bundledPath("schema", "prices.example.json");
|
|
1920
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1190
1921
|
};
|
|
1191
1922
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1192
1923
|
var renderCmd = defineCommand({
|
|
@@ -1219,12 +1950,11 @@ var renderCmd = defineCommand({
|
|
|
1219
1950
|
},
|
|
1220
1951
|
route: {
|
|
1221
1952
|
type: "string",
|
|
1222
|
-
description:
|
|
1223
|
-
required: true
|
|
1953
|
+
description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
|
|
1224
1954
|
},
|
|
1225
1955
|
effort: {
|
|
1226
1956
|
type: "string",
|
|
1227
|
-
description:
|
|
1957
|
+
description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
|
|
1228
1958
|
},
|
|
1229
1959
|
"test-report": {
|
|
1230
1960
|
type: "string",
|
|
@@ -1235,19 +1965,21 @@ var renderCmd = defineCommand({
|
|
|
1235
1965
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1236
1966
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1237
1967
|
const templatePath = resolveTemplatePath(args.template);
|
|
1238
|
-
const
|
|
1239
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
1968
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1969
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1240
1970
|
const template = readFileSync(templatePath, "utf-8");
|
|
1241
1971
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1242
1972
|
const output = render({
|
|
1243
1973
|
findings,
|
|
1244
1974
|
envelope,
|
|
1245
1975
|
prices,
|
|
1976
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1246
1977
|
template,
|
|
1247
1978
|
reviewedSha: args["reviewed-sha"],
|
|
1248
1979
|
route: args.route,
|
|
1249
1980
|
effort: args.effort,
|
|
1250
|
-
testReport
|
|
1981
|
+
testReport,
|
|
1982
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1251
1983
|
});
|
|
1252
1984
|
process.stdout.write(output);
|
|
1253
1985
|
}
|
|
@@ -1270,14 +2002,17 @@ var inlineCmd = defineCommand({
|
|
|
1270
2002
|
},
|
|
1271
2003
|
template: {
|
|
1272
2004
|
type: "string",
|
|
1273
|
-
description: "Path to inline comment Eta template (default:
|
|
2005
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1274
2006
|
}
|
|
1275
2007
|
},
|
|
1276
2008
|
run: async ({ args }) => {
|
|
1277
2009
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1278
2010
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1279
|
-
const inlineTemplate =
|
|
1280
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff,
|
|
2011
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
2012
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
2013
|
+
inlineTemplate,
|
|
2014
|
+
findings
|
|
2015
|
+
});
|
|
1281
2016
|
process.stdout.write(
|
|
1282
2017
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1283
2018
|
);
|
|
@@ -1307,8 +2042,217 @@ var costCmd = defineCommand({
|
|
|
1307
2042
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1308
2043
|
}
|
|
1309
2044
|
});
|
|
1310
|
-
var
|
|
1311
|
-
|
|
2045
|
+
var checkCostCmd = defineCommand({
|
|
2046
|
+
meta: {
|
|
2047
|
+
name: "check-cost",
|
|
2048
|
+
description: "Sum real USD spend from a live/finished Claude Code transcript tree (main session + subagents) against a price map \u2014 the correct cost when no clean result envelope exists (issue #36) and the outlook the review agent tracks in flight (issue #38)"
|
|
2049
|
+
},
|
|
2050
|
+
args: {
|
|
2051
|
+
transcript: {
|
|
2052
|
+
type: "positional",
|
|
2053
|
+
description: "Path to the session transcript JSONL (the hook's transcript_path)",
|
|
2054
|
+
required: true
|
|
2055
|
+
},
|
|
2056
|
+
prices: {
|
|
2057
|
+
type: "string",
|
|
2058
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
|
|
2059
|
+
}
|
|
2060
|
+
},
|
|
2061
|
+
run: async ({ args }) => {
|
|
2062
|
+
const tree = readTranscriptTree(resolve$1(args.transcript));
|
|
2063
|
+
if (tree.missing) {
|
|
2064
|
+
process.stderr.write(
|
|
2065
|
+
`code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
|
|
2066
|
+
`
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2070
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2071
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
2072
|
+
const report = computeCost(usage.models, prices);
|
|
2073
|
+
process.stdout.write(
|
|
2074
|
+
`${JSON.stringify(
|
|
2075
|
+
{
|
|
2076
|
+
...report,
|
|
2077
|
+
turns: usage.turns,
|
|
2078
|
+
durationMs: usage.durationMs,
|
|
2079
|
+
transcripts: tree.files,
|
|
2080
|
+
pricesProvided: priceResolution.kind === "provided"
|
|
2081
|
+
},
|
|
2082
|
+
null,
|
|
2083
|
+
2
|
|
2084
|
+
)}
|
|
2085
|
+
`
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
});
|
|
2089
|
+
var tryReadPrices = (path) => {
|
|
2090
|
+
try {
|
|
2091
|
+
const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
|
|
2092
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
2093
|
+
} catch {
|
|
2094
|
+
return null;
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
var parseBudgetUsd = (raw) => {
|
|
2098
|
+
if (raw === void 0) return null;
|
|
2099
|
+
const n = Number.parseFloat(raw);
|
|
2100
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
2101
|
+
};
|
|
2102
|
+
var transcriptPathOf = (input) => {
|
|
2103
|
+
const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
|
|
2104
|
+
return typeof tp === "string" ? tp : void 0;
|
|
2105
|
+
};
|
|
2106
|
+
var budgetHookCmd = defineCommand({
|
|
2107
|
+
meta: {
|
|
2108
|
+
name: "budget-hook",
|
|
2109
|
+
description: "Self-dispatching Claude Code hook for budget discipline (issue #38): on PostToolBatch, steer the agent to converge once spend or wall-clock enters its soft wind-down reserve; on PreToolUse, inside the hard reserve, deny the budget-burning tools (subagent spawns, arbitrary shell, web) while leaving the draft-delivery path open. Reads the hook payload on stdin, measures spend from its transcript, and decides on $ and/or time. Degrades to a no-op on any error."
|
|
2110
|
+
},
|
|
2111
|
+
args: {
|
|
2112
|
+
draft: {
|
|
2113
|
+
type: "string",
|
|
2114
|
+
description: "Path to the findings draft that is the sole permitted write target under forced convergence",
|
|
2115
|
+
required: true
|
|
2116
|
+
},
|
|
2117
|
+
"budget-usd": {
|
|
2118
|
+
type: "string",
|
|
2119
|
+
description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
|
|
2120
|
+
},
|
|
2121
|
+
wall: {
|
|
2122
|
+
type: "string",
|
|
2123
|
+
description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
|
|
2124
|
+
},
|
|
2125
|
+
prices: {
|
|
2126
|
+
type: "string",
|
|
2127
|
+
description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
|
|
2128
|
+
},
|
|
2129
|
+
"reserve-frac": {
|
|
2130
|
+
type: "string",
|
|
2131
|
+
description: "Wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
|
|
2132
|
+
},
|
|
2133
|
+
"reserve-usd": {
|
|
2134
|
+
type: "string",
|
|
2135
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2136
|
+
},
|
|
2137
|
+
"reserve-wall": {
|
|
2138
|
+
type: "string",
|
|
2139
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
run: async ({ args }) => {
|
|
2143
|
+
try {
|
|
2144
|
+
const draftPath = resolve$1(args.draft);
|
|
2145
|
+
const input = readStdinJSON();
|
|
2146
|
+
const transcriptPath = transcriptPathOf(input);
|
|
2147
|
+
const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
|
|
2148
|
+
const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
|
|
2149
|
+
const prices = args.prices ? tryReadPrices(args.prices) : null;
|
|
2150
|
+
const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
|
|
2151
|
+
const output = evaluateBudgetHook(input, {
|
|
2152
|
+
spentUsd,
|
|
2153
|
+
budgetUsd: parseBudgetUsd(args["budget-usd"]),
|
|
2154
|
+
elapsedMs: usage?.firstTsMs != null ? Math.max(0, Date.now() - usage.firstTsMs) : null,
|
|
2155
|
+
wallMs: args.wall ? parseWallMs(args.wall) : null,
|
|
2156
|
+
reserve: {
|
|
2157
|
+
frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
|
|
2158
|
+
flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
|
|
2159
|
+
flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
|
|
2160
|
+
},
|
|
2161
|
+
draftPath
|
|
2162
|
+
});
|
|
2163
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2164
|
+
`);
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
process.stderr.write(
|
|
2167
|
+
`code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
|
|
2168
|
+
`
|
|
2169
|
+
);
|
|
2170
|
+
process.stdout.write("{}\n");
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
var printSettingsCmd = defineCommand({
|
|
2175
|
+
meta: {
|
|
2176
|
+
name: "print-settings",
|
|
2177
|
+
description: "Emit ONE Claude Code --settings JSON composing the review agent's discipline (issue #38): the Stop deliverable gate plus the budget hooks (PreToolUse forced convergence + PostToolBatch steer) wired from one self-dispatching command. The review job generates this once and passes it as --settings."
|
|
2178
|
+
},
|
|
2179
|
+
args: {
|
|
2180
|
+
draft: {
|
|
2181
|
+
type: "string",
|
|
2182
|
+
description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
|
|
2183
|
+
required: true
|
|
2184
|
+
},
|
|
2185
|
+
kind: {
|
|
2186
|
+
type: "string",
|
|
2187
|
+
description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
|
|
2188
|
+
},
|
|
2189
|
+
schema: {
|
|
2190
|
+
type: "string",
|
|
2191
|
+
description: "Path to a schema file for the Stop gate (wins over --kind)"
|
|
2192
|
+
},
|
|
2193
|
+
"schema-version": {
|
|
2194
|
+
type: "string",
|
|
2195
|
+
description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
|
|
2196
|
+
},
|
|
2197
|
+
"max-nudges": {
|
|
2198
|
+
type: "string",
|
|
2199
|
+
description: "Stop-gate nudge budget before relenting (default: 5)"
|
|
2200
|
+
},
|
|
2201
|
+
counter: {
|
|
2202
|
+
type: "string",
|
|
2203
|
+
description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
|
|
2204
|
+
},
|
|
2205
|
+
"budget-usd": {
|
|
2206
|
+
type: "string",
|
|
2207
|
+
description: "Dollar budget the cost axis is measured against (needs --prices)"
|
|
2208
|
+
},
|
|
2209
|
+
wall: {
|
|
2210
|
+
type: "string",
|
|
2211
|
+
description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
|
|
2212
|
+
},
|
|
2213
|
+
prices: {
|
|
2214
|
+
type: "string",
|
|
2215
|
+
description: "Price map JSON to recompute real spend from the transcript"
|
|
2216
|
+
},
|
|
2217
|
+
"reserve-frac": {
|
|
2218
|
+
type: "string",
|
|
2219
|
+
description: "Wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
|
|
2220
|
+
},
|
|
2221
|
+
"reserve-usd": {
|
|
2222
|
+
type: "string",
|
|
2223
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2224
|
+
},
|
|
2225
|
+
"reserve-wall": {
|
|
2226
|
+
type: "string",
|
|
2227
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
|
|
2228
|
+
}
|
|
2229
|
+
},
|
|
2230
|
+
run: async ({ args }) => {
|
|
2231
|
+
if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
|
|
2232
|
+
fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
|
|
2233
|
+
const settings = composeReviewSettings({
|
|
2234
|
+
draftPath: resolve$1(args.draft),
|
|
2235
|
+
stop: {
|
|
2236
|
+
kind: args.kind,
|
|
2237
|
+
schema: args.schema,
|
|
2238
|
+
schemaVersion: args["schema-version"],
|
|
2239
|
+
maxNudges: args["max-nudges"],
|
|
2240
|
+
counter: args.counter
|
|
2241
|
+
},
|
|
2242
|
+
budget: {
|
|
2243
|
+
budgetUsd: args["budget-usd"],
|
|
2244
|
+
wall: args.wall,
|
|
2245
|
+
prices: args.prices,
|
|
2246
|
+
reserveFrac: args["reserve-frac"],
|
|
2247
|
+
reserveUsd: args["reserve-usd"],
|
|
2248
|
+
reserveWall: args["reserve-wall"]
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
process.stdout.write(`${JSON.stringify(settings)}
|
|
2252
|
+
`);
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
1312
2256
|
var validateCmd = defineCommand({
|
|
1313
2257
|
meta: {
|
|
1314
2258
|
name: "validate",
|
|
@@ -1367,11 +2311,22 @@ var adaptCmd = defineCommand({
|
|
|
1367
2311
|
"agent-file": {
|
|
1368
2312
|
type: "string",
|
|
1369
2313
|
description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
|
|
2314
|
+
},
|
|
2315
|
+
route: {
|
|
2316
|
+
type: "string",
|
|
2317
|
+
description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
|
|
2318
|
+
},
|
|
2319
|
+
effort: {
|
|
2320
|
+
type: "string",
|
|
2321
|
+
description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
|
|
1370
2322
|
}
|
|
1371
2323
|
},
|
|
1372
2324
|
run: async ({ args }) => {
|
|
1373
2325
|
const envelope = unwrapAdapt(
|
|
1374
|
-
adapt(requireAdapterName(args.adapter),
|
|
2326
|
+
adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
|
|
2327
|
+
route: args.route,
|
|
2328
|
+
effort: args.effort
|
|
2329
|
+
})
|
|
1375
2330
|
);
|
|
1376
2331
|
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
1377
2332
|
`);
|
|
@@ -1416,16 +2371,18 @@ var extractCmd = defineCommand({
|
|
|
1416
2371
|
run: async ({ args }) => {
|
|
1417
2372
|
requireAdapterName(args.adapter);
|
|
1418
2373
|
const kind = requireExtractSchemaKind(args.kind);
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
native: readJSON(args.native),
|
|
1422
|
-
agentFilePath: args["agent-file"]
|
|
1423
|
-
});
|
|
2374
|
+
const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
|
|
2375
|
+
const outcome = extractStructured(input);
|
|
1424
2376
|
if (outcome.kind === "ok") {
|
|
1425
2377
|
process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
|
|
1426
2378
|
`);
|
|
1427
2379
|
return;
|
|
1428
2380
|
}
|
|
2381
|
+
if (outcome.kind === "none" || outcome.kind === "ambiguous") {
|
|
2382
|
+
process.stderr.write(`extract: recovery failed \u2014
|
|
2383
|
+
${ladderFailureDiagnostics(input)}
|
|
2384
|
+
`);
|
|
2385
|
+
}
|
|
1429
2386
|
if (kind === "triage") {
|
|
1430
2387
|
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
1431
2388
|
`);
|
|
@@ -1434,6 +2391,70 @@ var extractCmd = defineCommand({
|
|
|
1434
2391
|
fail(describeLadderFailure(outcome));
|
|
1435
2392
|
}
|
|
1436
2393
|
});
|
|
2394
|
+
var withoutPatch = (finding) => {
|
|
2395
|
+
const copy = { ...finding };
|
|
2396
|
+
delete copy.patch;
|
|
2397
|
+
return copy;
|
|
2398
|
+
};
|
|
2399
|
+
var readFileLines = (path) => {
|
|
2400
|
+
try {
|
|
2401
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
2402
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
2403
|
+
} catch {
|
|
2404
|
+
return null;
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
var validateFinding = (finding, repoRoot) => {
|
|
2408
|
+
if (finding.patch === void 0) return finding;
|
|
2409
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
2410
|
+
if (lines === null) {
|
|
2411
|
+
process.stderr.write(
|
|
2412
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
2413
|
+
`
|
|
2414
|
+
);
|
|
2415
|
+
return withoutPatch(finding);
|
|
2416
|
+
}
|
|
2417
|
+
const result = validatePatch(finding.patch, lines);
|
|
2418
|
+
switch (result.kind) {
|
|
2419
|
+
case "anchored":
|
|
2420
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
2421
|
+
case "keep":
|
|
2422
|
+
return finding;
|
|
2423
|
+
case "drop":
|
|
2424
|
+
process.stderr.write(
|
|
2425
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
2426
|
+
`
|
|
2427
|
+
);
|
|
2428
|
+
return withoutPatch(finding);
|
|
2429
|
+
}
|
|
2430
|
+
};
|
|
2431
|
+
var validatePatchesCmd = defineCommand({
|
|
2432
|
+
meta: {
|
|
2433
|
+
name: "validate-patches",
|
|
2434
|
+
description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range and keeping the patch when it anchors, keeping it unaligned when it's a pure insertion, or dropping it when it doesn't apply (issue #10)"
|
|
2435
|
+
},
|
|
2436
|
+
args: {
|
|
2437
|
+
findings: {
|
|
2438
|
+
type: "positional",
|
|
2439
|
+
description: "Path to findings JSON",
|
|
2440
|
+
required: true
|
|
2441
|
+
},
|
|
2442
|
+
"repo-root": {
|
|
2443
|
+
type: "string",
|
|
2444
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
2445
|
+
}
|
|
2446
|
+
},
|
|
2447
|
+
run: async ({ args }) => {
|
|
2448
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
2449
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
2450
|
+
const validated = {
|
|
2451
|
+
...findings,
|
|
2452
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
2453
|
+
};
|
|
2454
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
2455
|
+
`);
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
1437
2458
|
var requireAdapterName = (name) => {
|
|
1438
2459
|
if (isAdapterName(name)) return name;
|
|
1439
2460
|
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
@@ -1456,7 +2477,7 @@ var requireSchemaPath = (kind, version) => {
|
|
|
1456
2477
|
var printSchemaCmd = defineCommand({
|
|
1457
2478
|
meta: {
|
|
1458
2479
|
name: "print-schema",
|
|
1459
|
-
description: "Print a bundled schema JSON"
|
|
2480
|
+
description: "Print a bundled schema JSON, ready to hand to a CLI's --json-schema (the $schema draft declaration is stripped)"
|
|
1460
2481
|
},
|
|
1461
2482
|
args: {
|
|
1462
2483
|
name: {
|
|
@@ -1472,7 +2493,103 @@ var printSchemaCmd = defineCommand({
|
|
|
1472
2493
|
run: async ({ args }) => {
|
|
1473
2494
|
const schemaKind = requireSchemaKind(args.name);
|
|
1474
2495
|
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1475
|
-
|
|
2496
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
2497
|
+
const enforcementSchema = Object.fromEntries(
|
|
2498
|
+
Object.entries(schema).filter(([key2]) => key2 !== "$schema")
|
|
2499
|
+
);
|
|
2500
|
+
process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
|
|
2501
|
+
`);
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
2505
|
+
var drainStdin = () => {
|
|
2506
|
+
if (process.stdin.isTTY) return;
|
|
2507
|
+
try {
|
|
2508
|
+
readFileSync(0);
|
|
2509
|
+
} catch {
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
var requireMaxNudges = (raw) => {
|
|
2513
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
2514
|
+
if (!/^\d+$/.test(raw)) {
|
|
2515
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
2516
|
+
}
|
|
2517
|
+
const n = Number.parseInt(raw, 10);
|
|
2518
|
+
if (n < 1) {
|
|
2519
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
2520
|
+
}
|
|
2521
|
+
return n;
|
|
2522
|
+
};
|
|
2523
|
+
var stopGateCmd = defineCommand({
|
|
2524
|
+
meta: {
|
|
2525
|
+
name: "stop-gate",
|
|
2526
|
+
description: "Claude Code Stop-hook gate: refuse to let the agent end its turn until --draft validates against the schema (bounded by --max-nudges). With --print-settings, emit the --settings JSON that wires this as the Stop hook."
|
|
2527
|
+
},
|
|
2528
|
+
args: {
|
|
2529
|
+
draft: {
|
|
2530
|
+
type: "string",
|
|
2531
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
2532
|
+
required: true
|
|
2533
|
+
},
|
|
2534
|
+
kind: {
|
|
2535
|
+
type: "string",
|
|
2536
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
2537
|
+
},
|
|
2538
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
2539
|
+
"schema-version": {
|
|
2540
|
+
type: "string",
|
|
2541
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
2542
|
+
},
|
|
2543
|
+
"max-nudges": {
|
|
2544
|
+
type: "string",
|
|
2545
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
2546
|
+
},
|
|
2547
|
+
counter: {
|
|
2548
|
+
type: "string",
|
|
2549
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
2550
|
+
},
|
|
2551
|
+
"print-settings": {
|
|
2552
|
+
type: "boolean",
|
|
2553
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
2554
|
+
}
|
|
2555
|
+
},
|
|
2556
|
+
run: async ({ args }) => {
|
|
2557
|
+
const draftPath = resolve$1(args.draft);
|
|
2558
|
+
if (args["print-settings"]) {
|
|
2559
|
+
const command = defaultHookCommand(draftPath, {
|
|
2560
|
+
kind: args.kind,
|
|
2561
|
+
schema: args.schema,
|
|
2562
|
+
schemaVersion: args["schema-version"],
|
|
2563
|
+
maxNudges: args["max-nudges"],
|
|
2564
|
+
counter: args.counter
|
|
2565
|
+
});
|
|
2566
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
2567
|
+
`);
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
drainStdin();
|
|
2571
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
2572
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
2573
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
2574
|
+
const state = draftState(
|
|
2575
|
+
draftPath,
|
|
2576
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
2577
|
+
);
|
|
2578
|
+
const nudges = readNudges(counterPath);
|
|
2579
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
2580
|
+
if (decision.kind === "block") {
|
|
2581
|
+
try {
|
|
2582
|
+
bumpNudges(counterPath, nudges);
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
process.stderr.write(
|
|
2585
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
|
|
2586
|
+
`
|
|
2587
|
+
);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
2591
|
+
`);
|
|
2592
|
+
}
|
|
1476
2593
|
}
|
|
1477
2594
|
});
|
|
1478
2595
|
var gatherCmd = defineCommand({
|
|
@@ -1559,12 +2676,11 @@ var postCmd = defineCommand({
|
|
|
1559
2676
|
},
|
|
1560
2677
|
"inline-template": {
|
|
1561
2678
|
type: "string",
|
|
1562
|
-
description: "Path to inline comment Eta template (default:
|
|
2679
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1563
2680
|
},
|
|
1564
2681
|
route: {
|
|
1565
2682
|
type: "string",
|
|
1566
|
-
description:
|
|
1567
|
-
required: true
|
|
2683
|
+
description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
|
|
1568
2684
|
},
|
|
1569
2685
|
"bot-login": {
|
|
1570
2686
|
type: "string",
|
|
@@ -1576,27 +2692,40 @@ var postCmd = defineCommand({
|
|
|
1576
2692
|
},
|
|
1577
2693
|
effort: {
|
|
1578
2694
|
type: "string",
|
|
1579
|
-
description:
|
|
2695
|
+
description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
|
|
1580
2696
|
},
|
|
1581
2697
|
"test-report": {
|
|
1582
2698
|
type: "string",
|
|
1583
2699
|
description: TEST_REPORT_DESCRIPTION
|
|
2700
|
+
},
|
|
2701
|
+
"run-url": {
|
|
2702
|
+
type: "string",
|
|
2703
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
2704
|
+
},
|
|
2705
|
+
"json-url": {
|
|
2706
|
+
type: "string",
|
|
2707
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1584
2708
|
}
|
|
1585
2709
|
},
|
|
1586
2710
|
run: async ({ args }) => {
|
|
2711
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1587
2712
|
await post({
|
|
1588
2713
|
repo: args.repo,
|
|
1589
2714
|
headSha: args["head-sha"],
|
|
1590
2715
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1591
2716
|
findingsPath: args.findings,
|
|
1592
2717
|
envelopePath: args.usage,
|
|
1593
|
-
pricesPath:
|
|
2718
|
+
pricesPath: priceResolution.path,
|
|
2719
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1594
2720
|
templatePath: resolveTemplatePath(args.template),
|
|
1595
|
-
inlineTemplatePath:
|
|
2721
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1596
2722
|
route: args.route,
|
|
1597
2723
|
headBranch: args["head-branch"],
|
|
1598
2724
|
effort: args.effort,
|
|
1599
|
-
testReportPath: args["test-report"]
|
|
2725
|
+
testReportPath: args["test-report"],
|
|
2726
|
+
runUrl: args["run-url"],
|
|
2727
|
+
jsonUrl: args["json-url"],
|
|
2728
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1600
2729
|
});
|
|
1601
2730
|
}
|
|
1602
2731
|
});
|
|
@@ -1604,7 +2733,7 @@ var main = defineCommand({
|
|
|
1604
2733
|
meta: {
|
|
1605
2734
|
name: "code-review",
|
|
1606
2735
|
version: packageVersion,
|
|
1607
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost,
|
|
2736
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, stop-gate, budget-hook, and print-settings"
|
|
1608
2737
|
},
|
|
1609
2738
|
subCommands: {
|
|
1610
2739
|
gather: gatherCmd,
|
|
@@ -1612,10 +2741,15 @@ var main = defineCommand({
|
|
|
1612
2741
|
inline: inlineCmd,
|
|
1613
2742
|
post: postCmd,
|
|
1614
2743
|
cost: costCmd,
|
|
2744
|
+
"check-cost": checkCostCmd,
|
|
1615
2745
|
validate: validateCmd,
|
|
1616
2746
|
adapt: adaptCmd,
|
|
1617
2747
|
extract: extractCmd,
|
|
1618
|
-
"
|
|
2748
|
+
"validate-patches": validatePatchesCmd,
|
|
2749
|
+
"print-schema": printSchemaCmd,
|
|
2750
|
+
"stop-gate": stopGateCmd,
|
|
2751
|
+
"budget-hook": budgetHookCmd,
|
|
2752
|
+
"print-settings": printSettingsCmd
|
|
1619
2753
|
}
|
|
1620
2754
|
});
|
|
1621
2755
|
if (!process.env["VITEST"]) {
|