@jphutchins/code-review 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +1646 -195
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- 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 +71 -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, statSync, readdirSync } from 'fs';
|
|
4
|
+
import { resolve as resolve$1, join, dirname, basename } 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,44 +53,207 @@ 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 parseFindingsMarker = (body) => {
|
|
182
|
+
const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
|
|
183
|
+
const b64 = match?.[1];
|
|
184
|
+
if (b64 === void 0) return null;
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
192
|
+
var projectPatch = (patch) => {
|
|
193
|
+
if (patch === void 0) return { kind: "none" };
|
|
194
|
+
const lowered = patchToSuggestion(patch);
|
|
195
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
196
|
+
};
|
|
197
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
198
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
199
|
+
const sha7 = headSha.slice(0, 7);
|
|
200
|
+
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.`;
|
|
201
|
+
return marker ? `${marker}
|
|
202
|
+
|
|
203
|
+
${linkLine}` : linkLine;
|
|
204
|
+
};
|
|
205
|
+
|
|
56
206
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
207
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
208
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
209
|
var sanitizeFinding = (f) => ({
|
|
61
210
|
...f,
|
|
62
211
|
title: escapePipes(f.title),
|
|
63
212
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
213
|
+
patchProjection: projectPatch(f.patch)
|
|
214
|
+
});
|
|
215
|
+
var emptySeverityCounts = () => ({
|
|
216
|
+
critical: 0,
|
|
217
|
+
major: 0,
|
|
218
|
+
minor: 0,
|
|
219
|
+
nit: 0
|
|
65
220
|
});
|
|
221
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
222
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
223
|
+
emptySeverityCounts()
|
|
224
|
+
);
|
|
66
225
|
var render = (input) => {
|
|
67
226
|
const eta = new Eta({ autoTrim: false });
|
|
68
227
|
const usageAvailable = input.envelope !== null;
|
|
69
228
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
229
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
70
230
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
71
231
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
72
232
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
73
|
-
const findings = input.findings.findings.map(sanitizeFinding);
|
|
74
|
-
const safeFindings = { ...input.findings, findings };
|
|
75
|
-
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
76
233
|
return eta.renderString(input.template, {
|
|
77
|
-
findings:
|
|
234
|
+
findings: input.findings,
|
|
78
235
|
envelope: input.envelope,
|
|
79
236
|
usageAvailable,
|
|
80
237
|
costReport,
|
|
238
|
+
pricesProvided,
|
|
81
239
|
route,
|
|
82
240
|
effort,
|
|
83
241
|
modelNames,
|
|
84
242
|
testReport: input.testReport ?? null,
|
|
85
243
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
244
|
+
postedAt: input.postedAt ?? "",
|
|
245
|
+
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
246
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
247
|
+
unanchoredCount: input.unanchoredCount ?? 0,
|
|
248
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
249
|
+
runUrl: input.runUrl ?? null,
|
|
250
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
251
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
252
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
92
253
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
93
|
-
|
|
254
|
+
// Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
|
|
255
|
+
// real tokens spent, we simply have no rates to price them (SPEC §6.2).
|
|
256
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
94
257
|
formatDuration: (ms) => {
|
|
95
258
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
96
259
|
const s = Math.round(ms / 1e3);
|
|
@@ -108,20 +271,8 @@ var render = (input) => {
|
|
|
108
271
|
return `\u2753 ${v}`;
|
|
109
272
|
}
|
|
110
273
|
},
|
|
111
|
-
severityEmoji
|
|
112
|
-
|
|
113
|
-
case "critical":
|
|
114
|
-
return "\u{1F534}";
|
|
115
|
-
case "major":
|
|
116
|
-
return "\u{1F7E0}";
|
|
117
|
-
case "minor":
|
|
118
|
-
return "\u{1F535}";
|
|
119
|
-
case "nit":
|
|
120
|
-
return "\u26AA";
|
|
121
|
-
default:
|
|
122
|
-
return "\u2753";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
274
|
+
severityEmoji,
|
|
275
|
+
formatConfidence
|
|
125
276
|
});
|
|
126
277
|
};
|
|
127
278
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -178,33 +329,33 @@ var partitionFindings = (findings, index) => {
|
|
|
178
329
|
};
|
|
179
330
|
|
|
180
331
|
// src/inline.ts
|
|
181
|
-
var
|
|
182
|
-
var
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
parts.push(`\`\`\`suggestion
|
|
187
|
-
${safe}
|
|
188
|
-
\`\`\``);
|
|
189
|
-
}
|
|
190
|
-
return parts.join("\n\n");
|
|
191
|
-
};
|
|
192
|
-
var renderCommentBody = (f, eta, template) => {
|
|
193
|
-
return eta.renderString(template, {
|
|
332
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
333
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
334
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
335
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
336
|
+
eta.renderString(template, {
|
|
194
337
|
...f,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
338
|
+
patchProjection: projectPatch(f.patch),
|
|
339
|
+
severityEmoji,
|
|
340
|
+
formatConfidence,
|
|
341
|
+
modelsText,
|
|
342
|
+
jsonUrl: jsonUrl ?? null,
|
|
343
|
+
findingsPointer: pointer
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
347
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
199
348
|
const index = indexDiff(diff);
|
|
200
349
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
201
|
-
const eta =
|
|
350
|
+
const eta = new Eta({ autoTrim: false });
|
|
351
|
+
const modelsText = formatModels(models);
|
|
202
352
|
const comments = inDiff.map((f) => {
|
|
353
|
+
const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
|
|
203
354
|
const comment = {
|
|
204
355
|
path: f.path,
|
|
205
356
|
line: f.end_line,
|
|
206
357
|
side: defaultSide(f.side),
|
|
207
|
-
body:
|
|
358
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
208
359
|
};
|
|
209
360
|
if (f.start_line < f.end_line) {
|
|
210
361
|
return {
|
|
@@ -215,7 +366,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
|
|
|
215
366
|
}
|
|
216
367
|
return comment;
|
|
217
368
|
});
|
|
218
|
-
return { comments, strays };
|
|
369
|
+
return { comments, strays, inDiff };
|
|
219
370
|
};
|
|
220
371
|
var renderStraysSection = (strays) => {
|
|
221
372
|
if (strays.length === 0) return "";
|
|
@@ -231,6 +382,120 @@ var renderStraysSection = (strays) => {
|
|
|
231
382
|
...items
|
|
232
383
|
].join("\n");
|
|
233
384
|
};
|
|
385
|
+
var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
|
|
386
|
+
var readFileOrNull = (path) => {
|
|
387
|
+
try {
|
|
388
|
+
return readFileSync(path, "utf-8");
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// src/transcript.ts
|
|
395
|
+
var numField = (rec, key2) => {
|
|
396
|
+
const v = rec[key2];
|
|
397
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
398
|
+
};
|
|
399
|
+
var messageUsage = (entry) => {
|
|
400
|
+
const rec = asRecord(entry);
|
|
401
|
+
if (rec === null || rec["type"] !== "assistant") return null;
|
|
402
|
+
const msg = asRecord(rec["message"]);
|
|
403
|
+
if (msg === null) return null;
|
|
404
|
+
const model = msg["model"];
|
|
405
|
+
const usage = asRecord(msg["usage"]);
|
|
406
|
+
if (typeof model !== "string" || usage === null) return null;
|
|
407
|
+
const id = msg["id"];
|
|
408
|
+
return {
|
|
409
|
+
id: typeof id === "string" ? id : null,
|
|
410
|
+
model,
|
|
411
|
+
input: numField(usage, "input_tokens"),
|
|
412
|
+
output: numField(usage, "output_tokens"),
|
|
413
|
+
cacheRead: numField(usage, "cache_read_input_tokens"),
|
|
414
|
+
cacheWrite: numField(usage, "cache_creation_input_tokens")
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
var tsMsOf = (entry) => {
|
|
418
|
+
const rec = asRecord(entry);
|
|
419
|
+
const ts = rec?.["timestamp"];
|
|
420
|
+
if (typeof ts !== "string") return null;
|
|
421
|
+
const ms = Date.parse(ts);
|
|
422
|
+
return Number.isNaN(ms) ? null : ms;
|
|
423
|
+
};
|
|
424
|
+
var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
425
|
+
var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
426
|
+
try {
|
|
427
|
+
return [JSON.parse(line)];
|
|
428
|
+
} catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
var sumTranscriptUsage = (entries) => {
|
|
433
|
+
const summed = entries.reduce(
|
|
434
|
+
(acc, entry) => {
|
|
435
|
+
const u = messageUsage(entry);
|
|
436
|
+
if (u === null) return acc;
|
|
437
|
+
if (u.id !== null && acc.seen.has(u.id)) return acc;
|
|
438
|
+
if (u.id !== null) acc.seen.add(u.id);
|
|
439
|
+
const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
|
|
440
|
+
acc.totals.set(u.model, {
|
|
441
|
+
input: prev.input + u.input,
|
|
442
|
+
output: prev.output + u.output,
|
|
443
|
+
cacheRead: prev.cacheRead + u.cacheRead,
|
|
444
|
+
cacheWrite: prev.cacheWrite + u.cacheWrite
|
|
445
|
+
});
|
|
446
|
+
return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
|
|
447
|
+
},
|
|
448
|
+
{ totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
|
|
449
|
+
);
|
|
450
|
+
const models = [...summed.totals].map(([model, t4]) => ({
|
|
451
|
+
model,
|
|
452
|
+
input_tokens: t4.input,
|
|
453
|
+
output_tokens: t4.output,
|
|
454
|
+
cache_read_tokens: t4.cacheRead,
|
|
455
|
+
cache_write_tokens: t4.cacheWrite
|
|
456
|
+
}));
|
|
457
|
+
const bounds = entries.reduce(
|
|
458
|
+
(acc, entry) => {
|
|
459
|
+
const ms = tsMsOf(entry);
|
|
460
|
+
if (ms === null) return acc;
|
|
461
|
+
return {
|
|
462
|
+
min: acc.min === null || ms < acc.min ? ms : acc.min,
|
|
463
|
+
max: acc.max === null || ms > acc.max ? ms : acc.max
|
|
464
|
+
};
|
|
465
|
+
},
|
|
466
|
+
{ min: null, max: null }
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
models,
|
|
470
|
+
turns: summed.turns,
|
|
471
|
+
durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
|
|
472
|
+
firstTsMs: bounds.min,
|
|
473
|
+
lastTsMs: bounds.max
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
var subagentFiles = (mainPath) => {
|
|
477
|
+
const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
|
|
478
|
+
try {
|
|
479
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
|
|
480
|
+
} catch {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
var readTranscriptTree = (mainPath) => {
|
|
485
|
+
const mainText = readFileOrNull(mainPath);
|
|
486
|
+
const mainEntries = mainText === null ? [] : parseJsonl(mainText);
|
|
487
|
+
const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
|
|
488
|
+
const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
|
|
489
|
+
const siblingReads = siblings.flatMap((path) => {
|
|
490
|
+
const text = readFileOrNull(path);
|
|
491
|
+
return text === null ? [] : [{ path, entries: parseJsonl(text) }];
|
|
492
|
+
});
|
|
493
|
+
return {
|
|
494
|
+
entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
|
|
495
|
+
files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
|
|
496
|
+
missing: mainText === null
|
|
497
|
+
};
|
|
498
|
+
};
|
|
234
499
|
var SeverityCodec = t.union([
|
|
235
500
|
t.literal("critical"),
|
|
236
501
|
t.literal("major"),
|
|
@@ -258,14 +523,16 @@ var FindingShape = t.intersection([
|
|
|
258
523
|
end_line: LineNumber,
|
|
259
524
|
severity: SeverityCodec,
|
|
260
525
|
title: t.string,
|
|
261
|
-
|
|
526
|
+
description: t.string,
|
|
527
|
+
reasoning: t.string,
|
|
528
|
+
confidence: Confidence
|
|
262
529
|
}),
|
|
263
530
|
t.partial({
|
|
264
531
|
side: SideCodec,
|
|
265
|
-
suggestion: t.union([t.string, t.null]),
|
|
266
|
-
confidence: Confidence,
|
|
267
532
|
code: t.string,
|
|
268
|
-
code_url: t.string
|
|
533
|
+
code_url: t.string,
|
|
534
|
+
recommendation: t.string,
|
|
535
|
+
patch: t.string
|
|
269
536
|
})
|
|
270
537
|
]);
|
|
271
538
|
var EndGeStart = t.refinement(
|
|
@@ -341,7 +608,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
341
608
|
failures: t.array(TestFailureCodec)
|
|
342
609
|
})
|
|
343
610
|
]);
|
|
344
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
611
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
612
|
+
var noticeFindings = (summary) => ({
|
|
613
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
614
|
+
summary,
|
|
615
|
+
verdict: "comment",
|
|
616
|
+
findings: []
|
|
617
|
+
});
|
|
345
618
|
|
|
346
619
|
// src/validate.ts
|
|
347
620
|
var addFormats = _addFormats;
|
|
@@ -377,10 +650,290 @@ var unsafeUnwrap = (decoded) => {
|
|
|
377
650
|
if (decoded._tag === "Right") return decoded.right;
|
|
378
651
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
379
652
|
};
|
|
653
|
+
|
|
654
|
+
// src/stop-gate.ts
|
|
655
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
656
|
+
switch (state.kind) {
|
|
657
|
+
case "missing":
|
|
658
|
+
return `${draftPath} does not exist yet`;
|
|
659
|
+
case "unreadable":
|
|
660
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
661
|
+
case "invalid":
|
|
662
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
663
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
667
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
668
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
669
|
+
return {
|
|
670
|
+
kind: "block",
|
|
671
|
+
reason: [
|
|
672
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
673
|
+
`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.`,
|
|
674
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
|
|
675
|
+
].join("\n")
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
679
|
+
let raw;
|
|
680
|
+
try {
|
|
681
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
682
|
+
} catch (err) {
|
|
683
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
684
|
+
return { kind: "missing" };
|
|
685
|
+
}
|
|
686
|
+
return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
|
|
687
|
+
}
|
|
688
|
+
let parsed;
|
|
689
|
+
try {
|
|
690
|
+
parsed = JSON.parse(raw);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return {
|
|
693
|
+
kind: "invalid",
|
|
694
|
+
errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
let schemaPath;
|
|
698
|
+
try {
|
|
699
|
+
schemaPath = resolveSchema(parsed);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
705
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
706
|
+
} catch (err) {
|
|
707
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
var readNudges = (counterPath) => {
|
|
711
|
+
try {
|
|
712
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
713
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
714
|
+
} catch {
|
|
715
|
+
return 0;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
var bumpNudges = (counterPath, current) => {
|
|
719
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
720
|
+
`);
|
|
721
|
+
};
|
|
722
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
723
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
724
|
+
"code-review stop-gate --draft",
|
|
725
|
+
shellQuote(draftPath),
|
|
726
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
727
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
728
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
729
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
730
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
731
|
+
].join(" ");
|
|
732
|
+
var stopHookSettings = (command) => ({
|
|
733
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// src/budget.ts
|
|
737
|
+
var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
|
|
738
|
+
var DEFAULT_RESERVE = {
|
|
739
|
+
frac: 0.15,
|
|
740
|
+
growth: 0.25,
|
|
741
|
+
flatUsd: 0.02,
|
|
742
|
+
flatMs: 12e4
|
|
743
|
+
};
|
|
744
|
+
var SOFT_MULTIPLE = 2;
|
|
745
|
+
var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
|
|
746
|
+
var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
|
|
747
|
+
var axisSeverity = (a, reserve) => {
|
|
748
|
+
const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
|
|
749
|
+
const effFrac = reserve.frac + reserve.growth * usedFrac;
|
|
750
|
+
const hardReserve = Math.max(a.flat, effFrac * a.limit);
|
|
751
|
+
const remaining = a.limit - a.used;
|
|
752
|
+
if (remaining <= hardReserve) return 2;
|
|
753
|
+
if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
|
|
754
|
+
return 0;
|
|
755
|
+
};
|
|
756
|
+
var decideBudget = (i) => {
|
|
757
|
+
const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
|
|
758
|
+
return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
|
|
759
|
+
};
|
|
760
|
+
var pct = (n) => `${String(Math.round(n * 100))}%`;
|
|
761
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
762
|
+
var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
|
|
763
|
+
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)}`;
|
|
764
|
+
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`;
|
|
765
|
+
var directive = (phase, draftPath, isSubagent) => {
|
|
766
|
+
if (isSubagent)
|
|
767
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
|
|
768
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
|
|
769
|
+
};
|
|
770
|
+
var budgetMessage = (i, phase, draftPath, isSubagent) => {
|
|
771
|
+
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
772
|
+
return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
|
|
773
|
+
};
|
|
774
|
+
var invokesCodeReviewValidate = (toolInput) => {
|
|
775
|
+
const cmd = asRecord(toolInput)?.["command"];
|
|
776
|
+
return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
|
|
777
|
+
};
|
|
778
|
+
var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
|
|
779
|
+
var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
|
|
780
|
+
var blockedDuringConvergence = (toolName, toolInput) => {
|
|
781
|
+
if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
|
|
782
|
+
if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
|
|
783
|
+
return false;
|
|
784
|
+
};
|
|
785
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
786
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
787
|
+
var writesToDraft = (toolName, toolInput, draftPath) => {
|
|
788
|
+
const rec = asRecord(toolInput);
|
|
789
|
+
const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
|
|
790
|
+
if (WRITE_TOOLS.has(toolName)) {
|
|
791
|
+
const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
|
|
792
|
+
if (typeof fp === "string" && targets.some((t4) => fp === t4 || basename(fp) === basename(t4)))
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
if (toolName === "Bash") {
|
|
796
|
+
const cmd = rec?.["command"];
|
|
797
|
+
if (typeof cmd !== "string") return false;
|
|
798
|
+
const alt = targets.map(escapeRegExp).join("|");
|
|
799
|
+
const end = "(?=$|[\\s|&;)])";
|
|
800
|
+
const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
|
|
801
|
+
const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
|
|
802
|
+
return redirect.test(cmd) || teeArg.test(cmd);
|
|
803
|
+
}
|
|
804
|
+
return false;
|
|
805
|
+
};
|
|
806
|
+
var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
|
|
807
|
+
var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
|
|
808
|
+
var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
|
|
809
|
+
var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
|
|
810
|
+
var forceBackgroundSpawn = (toolInput) => ({
|
|
811
|
+
hookSpecificOutput: {
|
|
812
|
+
hookEventName: "PreToolUse",
|
|
813
|
+
permissionDecision: "allow",
|
|
814
|
+
updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
var denyPreTool = (reason) => ({
|
|
818
|
+
hookSpecificOutput: {
|
|
819
|
+
hookEventName: "PreToolUse",
|
|
820
|
+
permissionDecision: "deny",
|
|
821
|
+
permissionDecisionReason: reason
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
var evaluateBudgetHook = (input, params) => {
|
|
825
|
+
const rec = asRecord(input);
|
|
826
|
+
const inputs = {
|
|
827
|
+
spentUsd: params.spentUsd,
|
|
828
|
+
budgetUsd: params.budgetUsd,
|
|
829
|
+
elapsedMs: params.elapsedMs,
|
|
830
|
+
wallMs: params.wallMs,
|
|
831
|
+
reserve: params.reserve
|
|
832
|
+
};
|
|
833
|
+
const phase = decideBudget(inputs);
|
|
834
|
+
const agentId = rec?.["agent_id"];
|
|
835
|
+
const isSubagent = typeof agentId === "string" && agentId.length > 0;
|
|
836
|
+
switch (rec?.["hook_event_name"]) {
|
|
837
|
+
case "PostToolBatch":
|
|
838
|
+
return phase.kind === "ok" ? {} : {
|
|
839
|
+
hookSpecificOutput: {
|
|
840
|
+
hookEventName: "PostToolBatch",
|
|
841
|
+
additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
case "PreToolUse": {
|
|
845
|
+
const toolName = rec["tool_name"];
|
|
846
|
+
if (typeof toolName !== "string") return {};
|
|
847
|
+
if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
|
|
848
|
+
return denyPreTool(singleWriterMessage(params.draftPath));
|
|
849
|
+
if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
|
|
850
|
+
return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
|
|
851
|
+
if (SPAWN_TOOLS.has(toolName)) {
|
|
852
|
+
if (!isSubagent && !params.mainDraftWritten)
|
|
853
|
+
return denyPreTool(spawnFloorMessage(params.draftPath));
|
|
854
|
+
return forceBackgroundSpawn(rec["tool_input"]);
|
|
855
|
+
}
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
default:
|
|
859
|
+
return {};
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
var parseWallMs = (raw) => {
|
|
863
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
|
|
864
|
+
if (m === null) return null;
|
|
865
|
+
const [, num = "", unit = "s"] = m;
|
|
866
|
+
const n = Number.parseFloat(num);
|
|
867
|
+
if (!Number.isFinite(n)) return null;
|
|
868
|
+
switch (unit) {
|
|
869
|
+
case "ms":
|
|
870
|
+
return n;
|
|
871
|
+
case "s":
|
|
872
|
+
return n * 1e3;
|
|
873
|
+
case "m":
|
|
874
|
+
return n * 6e4;
|
|
875
|
+
default:
|
|
876
|
+
return n * 36e5;
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
var parseEpochSecMs = (raw) => {
|
|
880
|
+
if (raw === void 0) return null;
|
|
881
|
+
const t4 = raw.trim();
|
|
882
|
+
if (!/^\d+$/.test(t4)) return null;
|
|
883
|
+
const n = Number.parseInt(t4, 10);
|
|
884
|
+
return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
|
|
885
|
+
};
|
|
886
|
+
var anchoredElapsedMs = (src) => {
|
|
887
|
+
if (src.deadlineMs !== null && src.wallMs !== null)
|
|
888
|
+
return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
|
|
889
|
+
if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
|
|
890
|
+
return null;
|
|
891
|
+
};
|
|
892
|
+
var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
|
|
893
|
+
var parseFraction = (raw, fallback) => {
|
|
894
|
+
if (raw === void 0) return fallback;
|
|
895
|
+
const n = Number.parseFloat(raw);
|
|
896
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
|
|
897
|
+
};
|
|
898
|
+
var budgetHookCommand = (draftPath, opts) => [
|
|
899
|
+
"code-review budget-hook --draft",
|
|
900
|
+
shellQuote(draftPath),
|
|
901
|
+
...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
|
|
902
|
+
...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
|
|
903
|
+
...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
|
|
904
|
+
...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
|
|
905
|
+
...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
|
|
906
|
+
...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
|
|
907
|
+
...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
|
|
908
|
+
].join(" ");
|
|
909
|
+
|
|
910
|
+
// src/format.ts
|
|
911
|
+
var FENCE_RE = /^\s*```/;
|
|
912
|
+
var scanLine = (state, line) => {
|
|
913
|
+
if (FENCE_RE.test(line)) {
|
|
914
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
915
|
+
}
|
|
916
|
+
if (state.inFence) {
|
|
917
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
918
|
+
}
|
|
919
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
920
|
+
if (trimmed !== "") {
|
|
921
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
922
|
+
}
|
|
923
|
+
const blankRun = state.blankRun + 1;
|
|
924
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
925
|
+
};
|
|
926
|
+
var formatMarkdown = (md) => {
|
|
927
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
928
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
929
|
+
`;
|
|
930
|
+
};
|
|
931
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
932
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
380
933
|
var identity = (decoded) => decoded;
|
|
381
934
|
var findingsTable = [
|
|
382
935
|
{
|
|
383
|
-
minor: "0.
|
|
936
|
+
minor: "0.4",
|
|
384
937
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
385
938
|
schemaFile: "findings.schema.json",
|
|
386
939
|
codec: FindingsCodec,
|
|
@@ -505,7 +1058,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
505
1058
|
// src/post.ts
|
|
506
1059
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
507
1060
|
var MAX_SUGGESTION_LINES = 10;
|
|
508
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
509
1061
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
510
1062
|
var checkLongSuggestions = (comments) => {
|
|
511
1063
|
const longFiles = [];
|
|
@@ -525,13 +1077,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
525
1077
|
});
|
|
526
1078
|
return { comments: adjusted, longFiles };
|
|
527
1079
|
};
|
|
528
|
-
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
529
|
-
var noticeFindings = (message) => ({
|
|
530
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
531
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
532
|
-
verdict: "comment",
|
|
533
|
-
findings: []
|
|
534
|
-
});
|
|
535
1080
|
var loadFindings = (path) => {
|
|
536
1081
|
let raw;
|
|
537
1082
|
try {
|
|
@@ -593,20 +1138,58 @@ var loadTestReport = (path) => {
|
|
|
593
1138
|
}
|
|
594
1139
|
return decoded.right;
|
|
595
1140
|
};
|
|
596
|
-
var
|
|
597
|
-
|
|
598
|
-
|
|
1141
|
+
var parseHtmlUrl = (raw) => {
|
|
1142
|
+
try {
|
|
1143
|
+
const parsed = JSON.parse(raw);
|
|
1144
|
+
return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return void 0;
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
var commentPayload = (c) => ({
|
|
1150
|
+
path: c.path,
|
|
1151
|
+
line: c.line,
|
|
1152
|
+
side: c.side,
|
|
1153
|
+
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
1154
|
+
body: formatMarkdown(c.body)
|
|
1155
|
+
});
|
|
1156
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
|
|
1157
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
1158
|
+
const reviewBody = (withComments) => JSON.stringify({
|
|
1159
|
+
body: pointer,
|
|
599
1160
|
commit_id: headSha,
|
|
600
1161
|
event: "COMMENT",
|
|
601
|
-
comments: comments.map(
|
|
602
|
-
path: c.path,
|
|
603
|
-
line: c.line,
|
|
604
|
-
side: c.side,
|
|
605
|
-
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
606
|
-
body: c.body
|
|
607
|
-
}))
|
|
1162
|
+
comments: withComments ? comments.map(commentPayload) : []
|
|
608
1163
|
});
|
|
609
|
-
|
|
1164
|
+
const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
|
|
1165
|
+
try {
|
|
1166
|
+
const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
|
|
1167
|
+
return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
if (comments.length === 0) throw err;
|
|
1170
|
+
process.stderr.write(
|
|
1171
|
+
`Warning: the batched inline review on PR #${String(prNumber)} was rejected (${err instanceof Error ? err.message : String(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
|
|
1172
|
+
`
|
|
1173
|
+
);
|
|
1174
|
+
const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
|
|
1175
|
+
const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
|
|
1176
|
+
const unposted = [];
|
|
1177
|
+
let inlinePosted = 0;
|
|
1178
|
+
for (const [i, c] of comments.entries()) {
|
|
1179
|
+
try {
|
|
1180
|
+
await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
|
|
1181
|
+
inlinePosted += 1;
|
|
1182
|
+
} catch (e) {
|
|
1183
|
+
const finding = inDiff[i];
|
|
1184
|
+
if (finding) unposted.push(finding);
|
|
1185
|
+
process.stderr.write(
|
|
1186
|
+
`Warning: inline comment on ${c.path}:${String(c.line)} rejected (${e instanceof Error ? e.message : String(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
|
|
1187
|
+
`
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return { url, inlinePosted, unposted };
|
|
1192
|
+
}
|
|
610
1193
|
};
|
|
611
1194
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
612
1195
|
const stdout = await ghApi(
|
|
@@ -626,33 +1209,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
626
1209
|
const parsed = JSON.parse(last);
|
|
627
1210
|
return { id: parsed.id, body: parsed.body };
|
|
628
1211
|
};
|
|
1212
|
+
var parseCommentRef = (raw) => {
|
|
1213
|
+
try {
|
|
1214
|
+
const parsed = JSON.parse(raw);
|
|
1215
|
+
return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
|
|
1216
|
+
} catch {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
629
1220
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
630
|
-
await ghApi(
|
|
1221
|
+
const stdout = await ghApi(
|
|
631
1222
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
632
1223
|
JSON.stringify({ body })
|
|
633
1224
|
);
|
|
1225
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
1226
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
634
1227
|
};
|
|
635
1228
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
636
|
-
await ghApi(
|
|
1229
|
+
const stdout = await ghApi(
|
|
637
1230
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
638
1231
|
JSON.stringify({ body })
|
|
639
1232
|
);
|
|
1233
|
+
return parseCommentRef(stdout);
|
|
640
1234
|
};
|
|
641
1235
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
642
1236
|
if (existing !== null) {
|
|
643
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
1237
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
644
1238
|
process.stderr.write(
|
|
645
1239
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
646
1240
|
`
|
|
647
1241
|
);
|
|
648
|
-
|
|
649
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
650
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
651
|
-
`);
|
|
1242
|
+
return { id: existing.id, url: patched?.html_url };
|
|
652
1243
|
}
|
|
1244
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
1245
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
1246
|
+
`);
|
|
1247
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
653
1248
|
};
|
|
654
1249
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
655
|
-
var
|
|
1250
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
656
1251
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
657
1252
|
let reviews;
|
|
658
1253
|
try {
|
|
@@ -661,10 +1256,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
661
1256
|
return [];
|
|
662
1257
|
}
|
|
663
1258
|
if (!Array.isArray(reviews)) return [];
|
|
664
|
-
return reviews.filter(
|
|
1259
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
|
|
665
1260
|
};
|
|
666
|
-
var
|
|
667
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
1261
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
668
1262
|
for (const id of ids) {
|
|
669
1263
|
try {
|
|
670
1264
|
await ghApi(
|
|
@@ -685,6 +1279,86 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
685
1279
|
}
|
|
686
1280
|
}
|
|
687
1281
|
};
|
|
1282
|
+
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}}}}}}}}";
|
|
1283
|
+
var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
|
|
1284
|
+
var priorBotCommentId = (c, logins) => {
|
|
1285
|
+
if (typeof c !== "object" || c === null) return null;
|
|
1286
|
+
const o = c;
|
|
1287
|
+
const login = o.author?.login;
|
|
1288
|
+
return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
|
|
1289
|
+
};
|
|
1290
|
+
var priorBotCommentIds = (raw, botLogin) => {
|
|
1291
|
+
let parsed;
|
|
1292
|
+
try {
|
|
1293
|
+
parsed = JSON.parse(raw);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return { ids: [], truncated: false };
|
|
1296
|
+
}
|
|
1297
|
+
const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
1298
|
+
const truncated = conn?.pageInfo?.hasNextPage === true;
|
|
1299
|
+
const nodes = conn?.nodes;
|
|
1300
|
+
if (!Array.isArray(nodes)) return { ids: [], truncated };
|
|
1301
|
+
const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
|
|
1302
|
+
const ids = nodes.flatMap((t4) => {
|
|
1303
|
+
const cnodes = t4.comments?.nodes;
|
|
1304
|
+
return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
|
|
1305
|
+
});
|
|
1306
|
+
return { ids, truncated };
|
|
1307
|
+
};
|
|
1308
|
+
var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
1309
|
+
const slash = repo.indexOf("/");
|
|
1310
|
+
if (slash <= 0) return [];
|
|
1311
|
+
const owner = repo.slice(0, slash);
|
|
1312
|
+
const name = repo.slice(slash + 1);
|
|
1313
|
+
let raw;
|
|
1314
|
+
try {
|
|
1315
|
+
raw = await ghApi([
|
|
1316
|
+
"graphql",
|
|
1317
|
+
"-f",
|
|
1318
|
+
`query=${REVIEW_THREAD_COMMENTS_QUERY}`,
|
|
1319
|
+
"-f",
|
|
1320
|
+
`owner=${owner}`,
|
|
1321
|
+
"-f",
|
|
1322
|
+
`name=${name}`,
|
|
1323
|
+
"-F",
|
|
1324
|
+
`pr=${String(prNumber)}`
|
|
1325
|
+
]);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
process.stderr.write(
|
|
1328
|
+
`Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
|
|
1329
|
+
`
|
|
1330
|
+
);
|
|
1331
|
+
return [];
|
|
1332
|
+
}
|
|
1333
|
+
const { ids, truncated } = priorBotCommentIds(raw, botLogin);
|
|
1334
|
+
if (truncated) {
|
|
1335
|
+
process.stderr.write(
|
|
1336
|
+
`Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
|
|
1337
|
+
`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
return ids;
|
|
1341
|
+
};
|
|
1342
|
+
var minimizeComments = async (prNumber, ids, ghApi) => {
|
|
1343
|
+
let minimized = 0;
|
|
1344
|
+
for (const id of ids) {
|
|
1345
|
+
try {
|
|
1346
|
+
await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
|
|
1347
|
+
minimized += 1;
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
process.stderr.write(
|
|
1350
|
+
`Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
|
|
1351
|
+
`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (minimized > 0) {
|
|
1356
|
+
process.stderr.write(
|
|
1357
|
+
`Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
|
|
1358
|
+
`
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
688
1362
|
var post = async (input, ghApi = runGhApi) => {
|
|
689
1363
|
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
690
1364
|
const resolution = resolvePr(candidates, input.headBranch);
|
|
@@ -709,24 +1383,28 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
709
1383
|
DEFAULT_MARKER,
|
|
710
1384
|
ghApi
|
|
711
1385
|
);
|
|
712
|
-
const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
|
|
713
|
-
const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
|
|
714
1386
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
715
1387
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
716
1388
|
if (decodedPrices._tag === "Left") {
|
|
717
1389
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
718
1390
|
}
|
|
719
1391
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
720
|
-
const inlineTemplate =
|
|
721
|
-
const renderNotice = (message) =>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1392
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
1393
|
+
const renderNotice = (message) => formatMarkdown(
|
|
1394
|
+
render({
|
|
1395
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
1396
|
+
envelope: null,
|
|
1397
|
+
prices: decodedPrices.right,
|
|
1398
|
+
pricesProvided: input.pricesProvided,
|
|
1399
|
+
template,
|
|
1400
|
+
route: input.route,
|
|
1401
|
+
reviewedSha: input.headSha,
|
|
1402
|
+
effort: input.effort,
|
|
1403
|
+
runUrl: input.runUrl,
|
|
1404
|
+
jsonUrl: input.jsonUrl,
|
|
1405
|
+
postedAt: input.postedAt
|
|
1406
|
+
})
|
|
1407
|
+
);
|
|
730
1408
|
if (isEmptyDiff(diff)) {
|
|
731
1409
|
await upsertSticky(
|
|
732
1410
|
input.repo,
|
|
@@ -752,27 +1430,40 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
752
1430
|
const envelope = loadEnvelope(input.envelopePath);
|
|
753
1431
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
754
1432
|
if (envelope === null) {
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1433
|
+
const body = formatMarkdown(
|
|
1434
|
+
render({
|
|
1435
|
+
findings,
|
|
1436
|
+
envelope: null,
|
|
1437
|
+
prices: decodedPrices.right,
|
|
1438
|
+
pricesProvided: input.pricesProvided,
|
|
1439
|
+
template,
|
|
1440
|
+
route: input.route,
|
|
1441
|
+
reviewedSha: input.headSha,
|
|
1442
|
+
effort: input.effort,
|
|
1443
|
+
testReport,
|
|
1444
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
1445
|
+
runUrl: input.runUrl,
|
|
1446
|
+
jsonUrl: input.jsonUrl,
|
|
1447
|
+
postedAt: input.postedAt
|
|
1448
|
+
})
|
|
1449
|
+
);
|
|
1450
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
766
1451
|
process.stderr.write(
|
|
767
1452
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
768
1453
|
);
|
|
769
1454
|
process.exit(0);
|
|
770
1455
|
}
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1456
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
1457
|
+
const {
|
|
1458
|
+
comments: rawComments,
|
|
1459
|
+
strays,
|
|
1460
|
+
inDiff
|
|
1461
|
+
} = buildInlineComments(findings.findings, diff, {
|
|
1462
|
+
inlineTemplate,
|
|
1463
|
+
models: envelope.models.map((m) => m.model),
|
|
1464
|
+
findings,
|
|
1465
|
+
jsonUrl: input.jsonUrl
|
|
1466
|
+
});
|
|
776
1467
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
777
1468
|
for (const wf of longFiles) {
|
|
778
1469
|
process.stderr.write(
|
|
@@ -780,43 +1471,97 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
780
1471
|
`
|
|
781
1472
|
);
|
|
782
1473
|
}
|
|
783
|
-
|
|
1474
|
+
const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
|
|
1475
|
+
const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
1476
|
+
const commonRenderInput = {
|
|
784
1477
|
findings,
|
|
785
1478
|
envelope,
|
|
786
1479
|
prices: decodedPrices.right,
|
|
1480
|
+
pricesProvided: input.pricesProvided,
|
|
787
1481
|
template,
|
|
788
1482
|
route: input.route,
|
|
789
1483
|
reviewedSha: input.headSha,
|
|
790
1484
|
effort: input.effort,
|
|
791
|
-
testReport
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1485
|
+
testReport,
|
|
1486
|
+
severityCounts: computeSeverityCounts(findings.findings),
|
|
1487
|
+
strays,
|
|
1488
|
+
runUrl: input.runUrl,
|
|
1489
|
+
jsonUrl: input.jsonUrl,
|
|
1490
|
+
findingsPointer: findingsMarker,
|
|
1491
|
+
postedAt: input.postedAt
|
|
1492
|
+
};
|
|
1493
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
797
1494
|
|
|
798
1495
|
---
|
|
799
1496
|
|
|
800
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1497
|
+
> **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.
|
|
1498
|
+
` : "";
|
|
1499
|
+
const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
|
|
1500
|
+
render({
|
|
1501
|
+
...commonRenderInput,
|
|
1502
|
+
...straysOverride ? { strays: straysOverride } : {},
|
|
1503
|
+
...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
|
|
1504
|
+
inlineDisposition,
|
|
1505
|
+
reviewUrl: reviewUrl2
|
|
1506
|
+
}) + longFilesNote
|
|
1507
|
+
);
|
|
1508
|
+
const stickyRef = await upsertSticky(
|
|
1509
|
+
input.repo,
|
|
1510
|
+
prNumber,
|
|
1511
|
+
existingSticky,
|
|
1512
|
+
renderBody(initialDisposition),
|
|
1513
|
+
ghApi
|
|
1514
|
+
);
|
|
1515
|
+
const priorInlineComments = await listPriorBotCommentIds(
|
|
1516
|
+
input.repo,
|
|
1517
|
+
prNumber,
|
|
1518
|
+
input.botLogin,
|
|
1519
|
+
ghApi
|
|
1520
|
+
);
|
|
1521
|
+
const {
|
|
1522
|
+
url: reviewUrl,
|
|
1523
|
+
inlinePosted,
|
|
1524
|
+
unposted
|
|
1525
|
+
} = await postInlineReview(
|
|
1526
|
+
input.repo,
|
|
1527
|
+
prNumber,
|
|
1528
|
+
input.headSha,
|
|
1529
|
+
comments,
|
|
1530
|
+
inDiff,
|
|
1531
|
+
stickyRef?.url,
|
|
1532
|
+
findingsMarker,
|
|
1533
|
+
ghApi
|
|
1534
|
+
);
|
|
1535
|
+
process.stderr.write(
|
|
1536
|
+
`Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
|
|
810
1537
|
`
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1538
|
+
);
|
|
1539
|
+
const priorReviewIds = botReviews.map((r) => r.id);
|
|
1540
|
+
if (priorReviewIds.length > 0) {
|
|
1541
|
+
await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
|
|
1542
|
+
}
|
|
1543
|
+
await minimizeComments(prNumber, priorInlineComments, ghApi);
|
|
1544
|
+
const unanchoredCount = unposted.length;
|
|
1545
|
+
const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
|
|
1546
|
+
if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
|
|
1547
|
+
const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
|
|
1548
|
+
try {
|
|
1549
|
+
await patchComment(
|
|
1550
|
+
input.repo,
|
|
1551
|
+
stickyRef.id,
|
|
1552
|
+
renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
|
|
1553
|
+
ghApi
|
|
1554
|
+
);
|
|
1555
|
+
process.stderr.write(
|
|
1556
|
+
`Updated sticky comment #${String(stickyRef.id)} to reflect the review
|
|
818
1557
|
`
|
|
819
|
-
|
|
1558
|
+
);
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
process.stderr.write(
|
|
1561
|
+
`Warning: failed to update the sticky summary after the review: ${err instanceof Error ? err.message : String(err)}
|
|
1562
|
+
`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
820
1565
|
}
|
|
821
1566
|
};
|
|
822
1567
|
var renderOutputs = (result) => {
|
|
@@ -955,6 +1700,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
955
1700
|
diffSize: Buffer.byteLength(diff, "utf8")
|
|
956
1701
|
};
|
|
957
1702
|
};
|
|
1703
|
+
|
|
1704
|
+
// src/extract.ts
|
|
958
1705
|
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
959
1706
|
var parseNativeForExtraction = (raw) => ({
|
|
960
1707
|
result: fieldOf(raw, "result"),
|
|
@@ -1014,13 +1761,6 @@ var tryParseJson = (text) => {
|
|
|
1014
1761
|
return { ok: false };
|
|
1015
1762
|
}
|
|
1016
1763
|
};
|
|
1017
|
-
var readFileOrNull = (path) => {
|
|
1018
|
-
try {
|
|
1019
|
-
return readFileSync(path, "utf-8");
|
|
1020
|
-
} catch {
|
|
1021
|
-
return null;
|
|
1022
|
-
}
|
|
1023
|
-
};
|
|
1024
1764
|
var candidateFromJsonText = (kind, text) => {
|
|
1025
1765
|
if (text === null) return null;
|
|
1026
1766
|
const parsed = tryParseJson(text);
|
|
@@ -1028,7 +1768,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1028
1768
|
};
|
|
1029
1769
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1030
1770
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1031
|
-
var
|
|
1771
|
+
var scanLine2 = (state, line) => {
|
|
1032
1772
|
if (state.openLength === null) {
|
|
1033
1773
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1034
1774
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1037,7 +1777,27 @@ var scanLine = (state, line) => {
|
|
|
1037
1777
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1038
1778
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1039
1779
|
};
|
|
1040
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
1780
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1781
|
+
var ladderFailureDiagnostics = (input) => {
|
|
1782
|
+
const native = parseNativeForExtraction(input.native);
|
|
1783
|
+
const preview = (s) => {
|
|
1784
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
1785
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
|
|
1786
|
+
};
|
|
1787
|
+
const lines = [];
|
|
1788
|
+
if (input.kind === "findings") {
|
|
1789
|
+
lines.push(
|
|
1790
|
+
input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
lines.push(
|
|
1794
|
+
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"
|
|
1795
|
+
);
|
|
1796
|
+
lines.push(
|
|
1797
|
+
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"
|
|
1798
|
+
);
|
|
1799
|
+
return lines.join("\n");
|
|
1800
|
+
};
|
|
1041
1801
|
var describeLadderFailure = (outcome) => {
|
|
1042
1802
|
switch (outcome.kind) {
|
|
1043
1803
|
case "error-envelope":
|
|
@@ -1120,38 +1880,92 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1120
1880
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1121
1881
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1122
1882
|
}));
|
|
1123
|
-
var
|
|
1124
|
-
const
|
|
1125
|
-
if (
|
|
1126
|
-
return
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1883
|
+
var findingsOutcome = (native, agentFilePath) => {
|
|
1884
|
+
const ladder = extractStructured({ kind: "findings", native, agentFilePath });
|
|
1885
|
+
if (ladder.kind !== "ok")
|
|
1886
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
1887
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
1888
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
1889
|
+
kind: "telemetry-only",
|
|
1890
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
1891
|
+
};
|
|
1892
|
+
};
|
|
1893
|
+
var withMeta = (base, meta) => ({
|
|
1894
|
+
...base,
|
|
1895
|
+
...meta.route ? { route: meta.route } : {},
|
|
1896
|
+
...meta.effort ? { effort: meta.effort } : {}
|
|
1897
|
+
});
|
|
1898
|
+
var resolveTelemetry = (native, meta) => {
|
|
1899
|
+
const fb = (() => {
|
|
1900
|
+
try {
|
|
1901
|
+
return meta.transcriptFallback?.();
|
|
1902
|
+
} catch {
|
|
1903
|
+
return void 0;
|
|
1904
|
+
}
|
|
1905
|
+
})();
|
|
1906
|
+
const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
|
|
1907
|
+
return withMeta(
|
|
1908
|
+
{
|
|
1909
|
+
models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
|
|
1910
|
+
...wallTurns,
|
|
1911
|
+
vendor_cost_usd: native.vendorCostUsd
|
|
1912
|
+
},
|
|
1913
|
+
meta
|
|
1914
|
+
);
|
|
1915
|
+
};
|
|
1916
|
+
var nativeTelemetry = (native, meta) => resolveTelemetry(
|
|
1917
|
+
{
|
|
1918
|
+
models: mapModelUsage(native.modelUsage),
|
|
1919
|
+
turns: native.num_turns,
|
|
1920
|
+
durationMs: native.duration_ms,
|
|
1921
|
+
vendorCostUsd: native.total_cost_usd ?? null
|
|
1922
|
+
},
|
|
1923
|
+
meta
|
|
1924
|
+
);
|
|
1925
|
+
var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
|
|
1926
|
+
var buildEnvelope = (telemetry, native, agentFilePath) => {
|
|
1927
|
+
const outcome = findingsOutcome(native, agentFilePath);
|
|
1928
|
+
switch (outcome.kind) {
|
|
1929
|
+
case "ok":
|
|
1930
|
+
return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
|
|
1931
|
+
case "telemetry-only":
|
|
1932
|
+
return {
|
|
1933
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1934
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
1935
|
+
|
|
1936
|
+
${outcome.reason}`),
|
|
1937
|
+
...telemetry
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1141
1940
|
};
|
|
1142
1941
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1143
1942
|
switch (adapterName) {
|
|
1144
1943
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1145
1944
|
case "claude-code": {
|
|
1945
|
+
if (native === void 0 || native === null)
|
|
1946
|
+
return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
|
|
1146
1947
|
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1147
|
-
if (decoded._tag === "Left")
|
|
1948
|
+
if (decoded._tag === "Left")
|
|
1148
1949
|
return left("native envelope does not match the Claude Code output shape");
|
|
1149
|
-
|
|
1150
|
-
return adaptClaudeCode(decoded.right, agentFilePath, meta);
|
|
1950
|
+
return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
|
|
1151
1951
|
}
|
|
1152
1952
|
}
|
|
1153
1953
|
};
|
|
1154
1954
|
|
|
1955
|
+
// src/settings.ts
|
|
1956
|
+
var composeReviewSettings = (opts) => {
|
|
1957
|
+
const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
|
|
1958
|
+
return {
|
|
1959
|
+
hooks: {
|
|
1960
|
+
Stop: [
|
|
1961
|
+
{ hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
|
|
1962
|
+
],
|
|
1963
|
+
PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
|
|
1964
|
+
PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1155
1969
|
// src/index.ts
|
|
1156
1970
|
var readJSON = (path) => {
|
|
1157
1971
|
try {
|
|
@@ -1166,6 +1980,54 @@ var fail = (msg) => {
|
|
|
1166
1980
|
`);
|
|
1167
1981
|
process.exit(1);
|
|
1168
1982
|
};
|
|
1983
|
+
var readJSONOrAbsent = (path) => {
|
|
1984
|
+
const read = (() => {
|
|
1985
|
+
try {
|
|
1986
|
+
return { text: readFileSync(resolve$1(path), "utf-8") };
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1989
|
+
}
|
|
1990
|
+
})();
|
|
1991
|
+
if ("error" in read) {
|
|
1992
|
+
process.stderr.write(
|
|
1993
|
+
`code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
|
|
1994
|
+
`
|
|
1995
|
+
);
|
|
1996
|
+
return void 0;
|
|
1997
|
+
}
|
|
1998
|
+
if (read.text.trim() === "") {
|
|
1999
|
+
process.stderr.write(
|
|
2000
|
+
`code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
|
|
2001
|
+
`
|
|
2002
|
+
);
|
|
2003
|
+
return void 0;
|
|
2004
|
+
}
|
|
2005
|
+
try {
|
|
2006
|
+
return JSON.parse(read.text);
|
|
2007
|
+
} catch (err) {
|
|
2008
|
+
process.stderr.write(
|
|
2009
|
+
`code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
|
|
2010
|
+
`
|
|
2011
|
+
);
|
|
2012
|
+
return void 0;
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
var readStdinJSON = () => {
|
|
2016
|
+
if (process.stdin.isTTY) return null;
|
|
2017
|
+
const raw = (() => {
|
|
2018
|
+
try {
|
|
2019
|
+
return readFileSync(0, "utf-8");
|
|
2020
|
+
} catch {
|
|
2021
|
+
return "";
|
|
2022
|
+
}
|
|
2023
|
+
})();
|
|
2024
|
+
if (raw.trim() === "") return null;
|
|
2025
|
+
try {
|
|
2026
|
+
return JSON.parse(raw);
|
|
2027
|
+
} catch {
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
1169
2031
|
var decode = (either, label) => {
|
|
1170
2032
|
try {
|
|
1171
2033
|
return unsafeUnwrap(either);
|
|
@@ -1183,15 +2045,26 @@ var unwrapAdapt = (either) => {
|
|
|
1183
2045
|
}
|
|
1184
2046
|
throw new Error("unreachable");
|
|
1185
2047
|
};
|
|
2048
|
+
var transcriptFallbackFrom = (path) => {
|
|
2049
|
+
const tree = readTranscriptTree(resolve$1(path));
|
|
2050
|
+
if (tree.missing)
|
|
2051
|
+
process.stderr.write(
|
|
2052
|
+
`code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback (issue #36)
|
|
2053
|
+
`
|
|
2054
|
+
);
|
|
2055
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2056
|
+
return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
|
|
2057
|
+
};
|
|
1186
2058
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1187
2059
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1188
2060
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1189
|
-
var
|
|
1190
|
-
|
|
2061
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
2062
|
+
var resolvePrices = (pricesArg) => {
|
|
2063
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1191
2064
|
process.stderr.write(
|
|
1192
|
-
"code-review: no --prices given \u2014
|
|
2065
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1193
2066
|
);
|
|
1194
|
-
return bundledPath("schema", "prices.example.json");
|
|
2067
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1195
2068
|
};
|
|
1196
2069
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1197
2070
|
var renderCmd = defineCommand({
|
|
@@ -1239,19 +2112,21 @@ var renderCmd = defineCommand({
|
|
|
1239
2112
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1240
2113
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1241
2114
|
const templatePath = resolveTemplatePath(args.template);
|
|
1242
|
-
const
|
|
1243
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
2115
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2116
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1244
2117
|
const template = readFileSync(templatePath, "utf-8");
|
|
1245
2118
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1246
2119
|
const output = render({
|
|
1247
2120
|
findings,
|
|
1248
2121
|
envelope,
|
|
1249
2122
|
prices,
|
|
2123
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1250
2124
|
template,
|
|
1251
2125
|
reviewedSha: args["reviewed-sha"],
|
|
1252
2126
|
route: args.route,
|
|
1253
2127
|
effort: args.effort,
|
|
1254
|
-
testReport
|
|
2128
|
+
testReport,
|
|
2129
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1255
2130
|
});
|
|
1256
2131
|
process.stdout.write(output);
|
|
1257
2132
|
}
|
|
@@ -1274,14 +2149,17 @@ var inlineCmd = defineCommand({
|
|
|
1274
2149
|
},
|
|
1275
2150
|
template: {
|
|
1276
2151
|
type: "string",
|
|
1277
|
-
description: "Path to inline comment Eta template (default:
|
|
2152
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1278
2153
|
}
|
|
1279
2154
|
},
|
|
1280
2155
|
run: async ({ args }) => {
|
|
1281
2156
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1282
2157
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1283
|
-
const inlineTemplate =
|
|
1284
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff,
|
|
2158
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
2159
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
2160
|
+
inlineTemplate,
|
|
2161
|
+
findings
|
|
2162
|
+
});
|
|
1285
2163
|
process.stdout.write(
|
|
1286
2164
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1287
2165
|
);
|
|
@@ -1311,8 +2189,273 @@ var costCmd = defineCommand({
|
|
|
1311
2189
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1312
2190
|
}
|
|
1313
2191
|
});
|
|
1314
|
-
var
|
|
1315
|
-
|
|
2192
|
+
var checkCostCmd = defineCommand({
|
|
2193
|
+
meta: {
|
|
2194
|
+
name: "check-cost",
|
|
2195
|
+
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)"
|
|
2196
|
+
},
|
|
2197
|
+
args: {
|
|
2198
|
+
transcript: {
|
|
2199
|
+
type: "positional",
|
|
2200
|
+
description: "Path to the session transcript JSONL (the hook's transcript_path)",
|
|
2201
|
+
required: true
|
|
2202
|
+
},
|
|
2203
|
+
prices: {
|
|
2204
|
+
type: "string",
|
|
2205
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
|
|
2206
|
+
}
|
|
2207
|
+
},
|
|
2208
|
+
run: async ({ args }) => {
|
|
2209
|
+
const tree = readTranscriptTree(resolve$1(args.transcript));
|
|
2210
|
+
if (tree.missing) {
|
|
2211
|
+
process.stderr.write(
|
|
2212
|
+
`code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
|
|
2213
|
+
`
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2217
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2218
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
2219
|
+
const report = computeCost(usage.models, prices);
|
|
2220
|
+
process.stdout.write(
|
|
2221
|
+
`${JSON.stringify(
|
|
2222
|
+
{
|
|
2223
|
+
...report,
|
|
2224
|
+
turns: usage.turns,
|
|
2225
|
+
durationMs: usage.durationMs,
|
|
2226
|
+
transcripts: tree.files,
|
|
2227
|
+
pricesProvided: priceResolution.kind === "provided"
|
|
2228
|
+
},
|
|
2229
|
+
null,
|
|
2230
|
+
2
|
|
2231
|
+
)}
|
|
2232
|
+
`
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
var tryReadPrices = (path) => {
|
|
2237
|
+
try {
|
|
2238
|
+
const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
|
|
2239
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
2240
|
+
} catch {
|
|
2241
|
+
return null;
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
var parseBudgetUsd = (raw) => {
|
|
2245
|
+
if (raw === void 0) return null;
|
|
2246
|
+
const n = Number.parseFloat(raw);
|
|
2247
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
2248
|
+
};
|
|
2249
|
+
var mtimeMsOf = (path) => {
|
|
2250
|
+
try {
|
|
2251
|
+
return statSync(path).mtimeMs;
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
var transcriptPathOf = (input) => {
|
|
2257
|
+
const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
|
|
2258
|
+
return typeof tp === "string" ? tp : void 0;
|
|
2259
|
+
};
|
|
2260
|
+
var budgetHookCmd = defineCommand({
|
|
2261
|
+
meta: {
|
|
2262
|
+
name: "budget-hook",
|
|
2263
|
+
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. At every phase, the main agent's subagent spawns are denied until it has written its own first-pass draft (seed-draft's sidecar marker tells the seed apart), and permitted spawns are rewritten to run in the background so no batch join can block the spawner (issue #73). 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."
|
|
2264
|
+
},
|
|
2265
|
+
args: {
|
|
2266
|
+
draft: {
|
|
2267
|
+
type: "string",
|
|
2268
|
+
description: "Path to the findings draft that is the sole permitted write target under forced convergence",
|
|
2269
|
+
required: true
|
|
2270
|
+
},
|
|
2271
|
+
"budget-usd": {
|
|
2272
|
+
type: "string",
|
|
2273
|
+
description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
|
|
2274
|
+
},
|
|
2275
|
+
wall: {
|
|
2276
|
+
type: "string",
|
|
2277
|
+
description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
|
|
2278
|
+
},
|
|
2279
|
+
prices: {
|
|
2280
|
+
type: "string",
|
|
2281
|
+
description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
|
|
2282
|
+
},
|
|
2283
|
+
"reserve-frac": {
|
|
2284
|
+
type: "string",
|
|
2285
|
+
description: "Base 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)"
|
|
2286
|
+
},
|
|
2287
|
+
"reserve-growth": {
|
|
2288
|
+
type: "string",
|
|
2289
|
+
description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
|
|
2290
|
+
},
|
|
2291
|
+
"reserve-usd": {
|
|
2292
|
+
type: "string",
|
|
2293
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2294
|
+
},
|
|
2295
|
+
"reserve-wall": {
|
|
2296
|
+
type: "string",
|
|
2297
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
|
|
2298
|
+
}
|
|
2299
|
+
},
|
|
2300
|
+
run: async ({ args }) => {
|
|
2301
|
+
try {
|
|
2302
|
+
const draftPath = resolve$1(args.draft);
|
|
2303
|
+
const input = readStdinJSON();
|
|
2304
|
+
const transcriptPath = transcriptPathOf(input);
|
|
2305
|
+
const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
|
|
2306
|
+
const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
|
|
2307
|
+
const prices = args.prices ? tryReadPrices(args.prices) : null;
|
|
2308
|
+
const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
|
|
2309
|
+
const wallMs = args.wall ? parseWallMs(args.wall) : null;
|
|
2310
|
+
const output = evaluateBudgetHook(input, {
|
|
2311
|
+
spentUsd,
|
|
2312
|
+
budgetUsd: parseBudgetUsd(args["budget-usd"]),
|
|
2313
|
+
elapsedMs: anchoredElapsedMs({
|
|
2314
|
+
deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
|
|
2315
|
+
wallMs,
|
|
2316
|
+
firstTsMs: usage?.firstTsMs ?? null,
|
|
2317
|
+
nowMs: Date.now()
|
|
2318
|
+
}),
|
|
2319
|
+
wallMs,
|
|
2320
|
+
reserve: {
|
|
2321
|
+
frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
|
|
2322
|
+
growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
|
|
2323
|
+
flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
|
|
2324
|
+
flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
|
|
2325
|
+
},
|
|
2326
|
+
draftPath,
|
|
2327
|
+
mainDraftWritten: mainHasWrittenDraft(
|
|
2328
|
+
mtimeMsOf(draftPath),
|
|
2329
|
+
mtimeMsOf(seedMarkerPath(draftPath))
|
|
2330
|
+
)
|
|
2331
|
+
});
|
|
2332
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2333
|
+
`);
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
process.stderr.write(
|
|
2336
|
+
`code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
|
|
2337
|
+
`
|
|
2338
|
+
);
|
|
2339
|
+
process.stdout.write("{}\n");
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
var printSettingsCmd = defineCommand({
|
|
2344
|
+
meta: {
|
|
2345
|
+
name: "print-settings",
|
|
2346
|
+
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."
|
|
2347
|
+
},
|
|
2348
|
+
args: {
|
|
2349
|
+
draft: {
|
|
2350
|
+
type: "string",
|
|
2351
|
+
description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
|
|
2352
|
+
required: true
|
|
2353
|
+
},
|
|
2354
|
+
kind: {
|
|
2355
|
+
type: "string",
|
|
2356
|
+
description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
|
|
2357
|
+
},
|
|
2358
|
+
schema: {
|
|
2359
|
+
type: "string",
|
|
2360
|
+
description: "Path to a schema file for the Stop gate (wins over --kind)"
|
|
2361
|
+
},
|
|
2362
|
+
"schema-version": {
|
|
2363
|
+
type: "string",
|
|
2364
|
+
description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
|
|
2365
|
+
},
|
|
2366
|
+
"max-nudges": {
|
|
2367
|
+
type: "string",
|
|
2368
|
+
description: "Stop-gate nudge budget before relenting (default: 5)"
|
|
2369
|
+
},
|
|
2370
|
+
counter: {
|
|
2371
|
+
type: "string",
|
|
2372
|
+
description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
|
|
2373
|
+
},
|
|
2374
|
+
"budget-usd": {
|
|
2375
|
+
type: "string",
|
|
2376
|
+
description: "Dollar budget the cost axis is measured against (needs --prices)"
|
|
2377
|
+
},
|
|
2378
|
+
wall: {
|
|
2379
|
+
type: "string",
|
|
2380
|
+
description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
|
|
2381
|
+
},
|
|
2382
|
+
prices: {
|
|
2383
|
+
type: "string",
|
|
2384
|
+
description: "Price map JSON to recompute real spend from the transcript"
|
|
2385
|
+
},
|
|
2386
|
+
"reserve-frac": {
|
|
2387
|
+
type: "string",
|
|
2388
|
+
description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
|
|
2389
|
+
},
|
|
2390
|
+
"reserve-growth": {
|
|
2391
|
+
type: "string",
|
|
2392
|
+
description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
|
|
2393
|
+
},
|
|
2394
|
+
"reserve-usd": {
|
|
2395
|
+
type: "string",
|
|
2396
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2397
|
+
},
|
|
2398
|
+
"reserve-wall": {
|
|
2399
|
+
type: "string",
|
|
2400
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
|
|
2401
|
+
}
|
|
2402
|
+
},
|
|
2403
|
+
run: async ({ args }) => {
|
|
2404
|
+
if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
|
|
2405
|
+
fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
|
|
2406
|
+
const settings = composeReviewSettings({
|
|
2407
|
+
draftPath: resolve$1(args.draft),
|
|
2408
|
+
stop: {
|
|
2409
|
+
kind: args.kind,
|
|
2410
|
+
schema: args.schema,
|
|
2411
|
+
schemaVersion: args["schema-version"],
|
|
2412
|
+
maxNudges: args["max-nudges"],
|
|
2413
|
+
counter: args.counter
|
|
2414
|
+
},
|
|
2415
|
+
budget: {
|
|
2416
|
+
budgetUsd: args["budget-usd"],
|
|
2417
|
+
wall: args.wall,
|
|
2418
|
+
prices: args.prices,
|
|
2419
|
+
reserveFrac: args["reserve-frac"],
|
|
2420
|
+
reserveGrowth: args["reserve-growth"],
|
|
2421
|
+
reserveUsd: args["reserve-usd"],
|
|
2422
|
+
reserveWall: args["reserve-wall"]
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
process.stdout.write(`${JSON.stringify(settings)}
|
|
2426
|
+
`);
|
|
2427
|
+
}
|
|
2428
|
+
});
|
|
2429
|
+
var deadlineCmd = defineCommand({
|
|
2430
|
+
meta: {
|
|
2431
|
+
name: "deadline",
|
|
2432
|
+
description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall). The review job exports this as CODE_REVIEW_DEADLINE_EPOCH right before `claude -p` so every budget hook \u2014 the main agent's and each fan-out subagent's \u2014 measures the SAME true remaining wall instead of its own transcript's start, which reads \u22480 in a fresh subagent and leaves the fan-out unsteered (issue #45)."
|
|
2433
|
+
},
|
|
2434
|
+
args: {
|
|
2435
|
+
wall: {
|
|
2436
|
+
type: "string",
|
|
2437
|
+
description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
|
|
2438
|
+
required: true
|
|
2439
|
+
}
|
|
2440
|
+
},
|
|
2441
|
+
run: async ({ args }) => {
|
|
2442
|
+
const wallMs = parseWallMs(args.wall);
|
|
2443
|
+
if (wallMs === null) {
|
|
2444
|
+
fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
|
|
2445
|
+
} else {
|
|
2446
|
+
process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
|
|
2447
|
+
`);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
2452
|
+
var printableSchema = (schemaPath) => {
|
|
2453
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
2454
|
+
const enforcementSchema = Object.fromEntries(
|
|
2455
|
+
Object.entries(schema).filter(([key2]) => key2 !== "$schema")
|
|
2456
|
+
);
|
|
2457
|
+
return JSON.stringify(enforcementSchema, null, 2);
|
|
2458
|
+
};
|
|
1316
2459
|
var validateCmd = defineCommand({
|
|
1317
2460
|
meta: {
|
|
1318
2461
|
name: "validate",
|
|
@@ -1335,6 +2478,10 @@ var validateCmd = defineCommand({
|
|
|
1335
2478
|
"schema-version": {
|
|
1336
2479
|
type: "string",
|
|
1337
2480
|
description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
|
|
2481
|
+
},
|
|
2482
|
+
explain: {
|
|
2483
|
+
type: "boolean",
|
|
2484
|
+
description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
|
|
1338
2485
|
}
|
|
1339
2486
|
},
|
|
1340
2487
|
run: async ({ args }) => {
|
|
@@ -1348,10 +2495,129 @@ var validateCmd = defineCommand({
|
|
|
1348
2495
|
process.stderr.write("\u274C invalid\n");
|
|
1349
2496
|
for (const e of errors) process.stderr.write(` - ${e}
|
|
1350
2497
|
`);
|
|
2498
|
+
if (args.explain) {
|
|
2499
|
+
process.stderr.write(
|
|
2500
|
+
`
|
|
2501
|
+
The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
|
|
2502
|
+
${printableSchema(schemaPath)}
|
|
2503
|
+
`
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
1351
2506
|
process.exit(1);
|
|
1352
2507
|
}
|
|
1353
2508
|
}
|
|
1354
2509
|
});
|
|
2510
|
+
var seedDraftCmd = defineCommand({
|
|
2511
|
+
meta: {
|
|
2512
|
+
name: "seed-draft",
|
|
2513
|
+
description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0 (issues #52, #53). Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself (issue #73). Prints the mode chosen (prior|empty|none \u2014 none when even the scaffold write failed) to stdout; always exits 0"
|
|
2514
|
+
},
|
|
2515
|
+
args: {
|
|
2516
|
+
prior: {
|
|
2517
|
+
type: "string",
|
|
2518
|
+
description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
|
|
2519
|
+
},
|
|
2520
|
+
out: {
|
|
2521
|
+
type: "string",
|
|
2522
|
+
description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
|
|
2523
|
+
required: true
|
|
2524
|
+
},
|
|
2525
|
+
kind: {
|
|
2526
|
+
type: "string",
|
|
2527
|
+
description: "Schema kind to validate the prior findings against (default: findings)"
|
|
2528
|
+
},
|
|
2529
|
+
schema: {
|
|
2530
|
+
type: "string",
|
|
2531
|
+
description: "Path to a schema file (wins over --kind/--schema-version)"
|
|
2532
|
+
},
|
|
2533
|
+
"schema-version": {
|
|
2534
|
+
type: "string",
|
|
2535
|
+
description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
|
|
2536
|
+
}
|
|
2537
|
+
},
|
|
2538
|
+
run: async ({ args }) => {
|
|
2539
|
+
const outPath = resolve$1(args.out);
|
|
2540
|
+
const kindArg = args.kind || "findings";
|
|
2541
|
+
const kind = isSchemaKind(kindArg) ? kindArg : "findings";
|
|
2542
|
+
if (kind !== kindArg) {
|
|
2543
|
+
process.stderr.write(
|
|
2544
|
+
`Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
|
|
2545
|
+
`
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
const writeSeedMarker = () => {
|
|
2549
|
+
try {
|
|
2550
|
+
writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
|
|
2551
|
+
} catch (err) {
|
|
2552
|
+
process.stderr.write(
|
|
2553
|
+
`Warning: could not write the seed marker beside ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the seeded draft will count as agent-written
|
|
2554
|
+
`
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
const writeEmptyScaffold = () => {
|
|
2559
|
+
try {
|
|
2560
|
+
writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
|
|
2561
|
+
`);
|
|
2562
|
+
writeSeedMarker();
|
|
2563
|
+
process.stderr.write(
|
|
2564
|
+
`Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
|
|
2565
|
+
`
|
|
2566
|
+
);
|
|
2567
|
+
process.stdout.write("empty\n");
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
process.stderr.write(
|
|
2570
|
+
`Warning: could not write the seed scaffold to ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the agent will create $DRAFT itself
|
|
2571
|
+
`
|
|
2572
|
+
);
|
|
2573
|
+
process.stdout.write("none\n");
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
const priorFindings = (() => {
|
|
2577
|
+
if (!args.prior) return null;
|
|
2578
|
+
const raw = (() => {
|
|
2579
|
+
try {
|
|
2580
|
+
return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
|
|
2581
|
+
} catch {
|
|
2582
|
+
return null;
|
|
2583
|
+
}
|
|
2584
|
+
})();
|
|
2585
|
+
const body = typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
|
|
2586
|
+
return body === null ? null : parseFindingsMarker(body);
|
|
2587
|
+
})();
|
|
2588
|
+
if (priorFindings === null) {
|
|
2589
|
+
writeEmptyScaffold();
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
const seededFromPrior = (() => {
|
|
2593
|
+
try {
|
|
2594
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
|
|
2595
|
+
if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
|
|
2596
|
+
writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
|
|
2597
|
+
`);
|
|
2598
|
+
writeSeedMarker();
|
|
2599
|
+
return true;
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
process.stderr.write(
|
|
2602
|
+
`Warning: could not seed from the prior review (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the empty scaffold
|
|
2603
|
+
`
|
|
2604
|
+
);
|
|
2605
|
+
return false;
|
|
2606
|
+
}
|
|
2607
|
+
})();
|
|
2608
|
+
if (seededFromPrior) {
|
|
2609
|
+
const priorList = priorFindings.findings;
|
|
2610
|
+
const count = Array.isArray(priorList) ? priorList.length : 0;
|
|
2611
|
+
process.stderr.write(
|
|
2612
|
+
`Seeded ${outPath} from the prior review (${String(count)} finding(s)) \u2014 verify each still holds against the current diff and refine in place
|
|
2613
|
+
`
|
|
2614
|
+
);
|
|
2615
|
+
process.stdout.write("prior\n");
|
|
2616
|
+
} else {
|
|
2617
|
+
writeEmptyScaffold();
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
});
|
|
1355
2621
|
var adaptCmd = defineCommand({
|
|
1356
2622
|
meta: {
|
|
1357
2623
|
name: "adapt",
|
|
@@ -1379,13 +2645,20 @@ var adaptCmd = defineCommand({
|
|
|
1379
2645
|
effort: {
|
|
1380
2646
|
type: "string",
|
|
1381
2647
|
description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
|
|
2648
|
+
},
|
|
2649
|
+
transcript: {
|
|
2650
|
+
type: "string",
|
|
2651
|
+
description: "Path to the session transcript (the main .jsonl). Its tree (main + subagents) is the source of the true wall + turn count \u2014 the native envelope only sees the main agent and under-reports a fan-out (issue #59) \u2014 and refills per-model usage too when the native has none (a wall-clock kill leaves it empty, so cost is real not $0.00 \u2014 issues #39/#36)"
|
|
1382
2652
|
}
|
|
1383
2653
|
},
|
|
1384
2654
|
run: async ({ args }) => {
|
|
1385
2655
|
const envelope = unwrapAdapt(
|
|
1386
|
-
adapt(requireAdapterName(args.adapter),
|
|
2656
|
+
adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
|
|
1387
2657
|
route: args.route,
|
|
1388
|
-
effort: args.effort
|
|
2658
|
+
effort: args.effort,
|
|
2659
|
+
...args.transcript ? {
|
|
2660
|
+
transcriptFallback: () => transcriptFallbackFrom(args.transcript)
|
|
2661
|
+
} : {}
|
|
1389
2662
|
})
|
|
1390
2663
|
);
|
|
1391
2664
|
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
@@ -1431,16 +2704,18 @@ var extractCmd = defineCommand({
|
|
|
1431
2704
|
run: async ({ args }) => {
|
|
1432
2705
|
requireAdapterName(args.adapter);
|
|
1433
2706
|
const kind = requireExtractSchemaKind(args.kind);
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
native: readJSON(args.native),
|
|
1437
|
-
agentFilePath: args["agent-file"]
|
|
1438
|
-
});
|
|
2707
|
+
const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
|
|
2708
|
+
const outcome = extractStructured(input);
|
|
1439
2709
|
if (outcome.kind === "ok") {
|
|
1440
2710
|
process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
|
|
1441
2711
|
`);
|
|
1442
2712
|
return;
|
|
1443
2713
|
}
|
|
2714
|
+
if (outcome.kind === "none" || outcome.kind === "ambiguous") {
|
|
2715
|
+
process.stderr.write(`extract: recovery failed \u2014
|
|
2716
|
+
${ladderFailureDiagnostics(input)}
|
|
2717
|
+
`);
|
|
2718
|
+
}
|
|
1444
2719
|
if (kind === "triage") {
|
|
1445
2720
|
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
1446
2721
|
`);
|
|
@@ -1449,6 +2724,70 @@ var extractCmd = defineCommand({
|
|
|
1449
2724
|
fail(describeLadderFailure(outcome));
|
|
1450
2725
|
}
|
|
1451
2726
|
});
|
|
2727
|
+
var withoutPatch = (finding) => {
|
|
2728
|
+
const copy = { ...finding };
|
|
2729
|
+
delete copy.patch;
|
|
2730
|
+
return copy;
|
|
2731
|
+
};
|
|
2732
|
+
var readFileLines = (path) => {
|
|
2733
|
+
try {
|
|
2734
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
2735
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
2736
|
+
} catch {
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
var validateFinding = (finding, repoRoot) => {
|
|
2741
|
+
if (finding.patch === void 0) return finding;
|
|
2742
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
2743
|
+
if (lines === null) {
|
|
2744
|
+
process.stderr.write(
|
|
2745
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
2746
|
+
`
|
|
2747
|
+
);
|
|
2748
|
+
return withoutPatch(finding);
|
|
2749
|
+
}
|
|
2750
|
+
const result = validatePatch(finding.patch, lines);
|
|
2751
|
+
switch (result.kind) {
|
|
2752
|
+
case "anchored":
|
|
2753
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
2754
|
+
case "keep":
|
|
2755
|
+
return finding;
|
|
2756
|
+
case "drop":
|
|
2757
|
+
process.stderr.write(
|
|
2758
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
2759
|
+
`
|
|
2760
|
+
);
|
|
2761
|
+
return withoutPatch(finding);
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
var validatePatchesCmd = defineCommand({
|
|
2765
|
+
meta: {
|
|
2766
|
+
name: "validate-patches",
|
|
2767
|
+
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)"
|
|
2768
|
+
},
|
|
2769
|
+
args: {
|
|
2770
|
+
findings: {
|
|
2771
|
+
type: "positional",
|
|
2772
|
+
description: "Path to findings JSON",
|
|
2773
|
+
required: true
|
|
2774
|
+
},
|
|
2775
|
+
"repo-root": {
|
|
2776
|
+
type: "string",
|
|
2777
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
2778
|
+
}
|
|
2779
|
+
},
|
|
2780
|
+
run: async ({ args }) => {
|
|
2781
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
2782
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
2783
|
+
const validated = {
|
|
2784
|
+
...findings,
|
|
2785
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
2786
|
+
};
|
|
2787
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
2788
|
+
`);
|
|
2789
|
+
}
|
|
2790
|
+
});
|
|
1452
2791
|
var requireAdapterName = (name) => {
|
|
1453
2792
|
if (isAdapterName(name)) return name;
|
|
1454
2793
|
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
@@ -1471,7 +2810,7 @@ var requireSchemaPath = (kind, version) => {
|
|
|
1471
2810
|
var printSchemaCmd = defineCommand({
|
|
1472
2811
|
meta: {
|
|
1473
2812
|
name: "print-schema",
|
|
1474
|
-
description: "Print a bundled schema JSON"
|
|
2813
|
+
description: "Print a bundled schema JSON, ready to hand to a CLI's --json-schema (the $schema draft declaration is stripped)"
|
|
1475
2814
|
},
|
|
1476
2815
|
args: {
|
|
1477
2816
|
name: {
|
|
@@ -1487,7 +2826,99 @@ var printSchemaCmd = defineCommand({
|
|
|
1487
2826
|
run: async ({ args }) => {
|
|
1488
2827
|
const schemaKind = requireSchemaKind(args.name);
|
|
1489
2828
|
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1490
|
-
process.stdout.write(
|
|
2829
|
+
process.stdout.write(`${printableSchema(schemaPath)}
|
|
2830
|
+
`);
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
2834
|
+
var drainStdin = () => {
|
|
2835
|
+
if (process.stdin.isTTY) return;
|
|
2836
|
+
try {
|
|
2837
|
+
readFileSync(0);
|
|
2838
|
+
} catch {
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
var requireMaxNudges = (raw) => {
|
|
2842
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
2843
|
+
if (!/^\d+$/.test(raw)) {
|
|
2844
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
2845
|
+
}
|
|
2846
|
+
const n = Number.parseInt(raw, 10);
|
|
2847
|
+
if (n < 1) {
|
|
2848
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
2849
|
+
}
|
|
2850
|
+
return n;
|
|
2851
|
+
};
|
|
2852
|
+
var stopGateCmd = defineCommand({
|
|
2853
|
+
meta: {
|
|
2854
|
+
name: "stop-gate",
|
|
2855
|
+
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."
|
|
2856
|
+
},
|
|
2857
|
+
args: {
|
|
2858
|
+
draft: {
|
|
2859
|
+
type: "string",
|
|
2860
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
2861
|
+
required: true
|
|
2862
|
+
},
|
|
2863
|
+
kind: {
|
|
2864
|
+
type: "string",
|
|
2865
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
2866
|
+
},
|
|
2867
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
2868
|
+
"schema-version": {
|
|
2869
|
+
type: "string",
|
|
2870
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
2871
|
+
},
|
|
2872
|
+
"max-nudges": {
|
|
2873
|
+
type: "string",
|
|
2874
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
2875
|
+
},
|
|
2876
|
+
counter: {
|
|
2877
|
+
type: "string",
|
|
2878
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
2879
|
+
},
|
|
2880
|
+
"print-settings": {
|
|
2881
|
+
type: "boolean",
|
|
2882
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
2883
|
+
}
|
|
2884
|
+
},
|
|
2885
|
+
run: async ({ args }) => {
|
|
2886
|
+
const draftPath = resolve$1(args.draft);
|
|
2887
|
+
if (args["print-settings"]) {
|
|
2888
|
+
const command = defaultHookCommand(draftPath, {
|
|
2889
|
+
kind: args.kind,
|
|
2890
|
+
schema: args.schema,
|
|
2891
|
+
schemaVersion: args["schema-version"],
|
|
2892
|
+
maxNudges: args["max-nudges"],
|
|
2893
|
+
counter: args.counter
|
|
2894
|
+
});
|
|
2895
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
2896
|
+
`);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
drainStdin();
|
|
2900
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
2901
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
2902
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
2903
|
+
const state = draftState(
|
|
2904
|
+
draftPath,
|
|
2905
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
2906
|
+
);
|
|
2907
|
+
const nudges = readNudges(counterPath);
|
|
2908
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
2909
|
+
if (decision.kind === "block") {
|
|
2910
|
+
try {
|
|
2911
|
+
bumpNudges(counterPath, nudges);
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
process.stderr.write(
|
|
2914
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
|
|
2915
|
+
`
|
|
2916
|
+
);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
2920
|
+
`);
|
|
2921
|
+
}
|
|
1491
2922
|
}
|
|
1492
2923
|
});
|
|
1493
2924
|
var gatherCmd = defineCommand({
|
|
@@ -1574,7 +3005,7 @@ var postCmd = defineCommand({
|
|
|
1574
3005
|
},
|
|
1575
3006
|
"inline-template": {
|
|
1576
3007
|
type: "string",
|
|
1577
|
-
description: "Path to inline comment Eta template (default:
|
|
3008
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1578
3009
|
},
|
|
1579
3010
|
route: {
|
|
1580
3011
|
type: "string",
|
|
@@ -1595,22 +3026,35 @@ var postCmd = defineCommand({
|
|
|
1595
3026
|
"test-report": {
|
|
1596
3027
|
type: "string",
|
|
1597
3028
|
description: TEST_REPORT_DESCRIPTION
|
|
3029
|
+
},
|
|
3030
|
+
"run-url": {
|
|
3031
|
+
type: "string",
|
|
3032
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
3033
|
+
},
|
|
3034
|
+
"json-url": {
|
|
3035
|
+
type: "string",
|
|
3036
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1598
3037
|
}
|
|
1599
3038
|
},
|
|
1600
3039
|
run: async ({ args }) => {
|
|
3040
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1601
3041
|
await post({
|
|
1602
3042
|
repo: args.repo,
|
|
1603
3043
|
headSha: args["head-sha"],
|
|
1604
3044
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1605
3045
|
findingsPath: args.findings,
|
|
1606
3046
|
envelopePath: args.usage,
|
|
1607
|
-
pricesPath:
|
|
3047
|
+
pricesPath: priceResolution.path,
|
|
3048
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1608
3049
|
templatePath: resolveTemplatePath(args.template),
|
|
1609
|
-
inlineTemplatePath:
|
|
3050
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1610
3051
|
route: args.route,
|
|
1611
3052
|
headBranch: args["head-branch"],
|
|
1612
3053
|
effort: args.effort,
|
|
1613
|
-
testReportPath: args["test-report"]
|
|
3054
|
+
testReportPath: args["test-report"],
|
|
3055
|
+
runUrl: args["run-url"],
|
|
3056
|
+
jsonUrl: args["json-url"],
|
|
3057
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1614
3058
|
});
|
|
1615
3059
|
}
|
|
1616
3060
|
});
|
|
@@ -1618,7 +3062,7 @@ var main = defineCommand({
|
|
|
1618
3062
|
meta: {
|
|
1619
3063
|
name: "code-review",
|
|
1620
3064
|
version: packageVersion,
|
|
1621
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost,
|
|
3065
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, seed-draft, stop-gate, budget-hook, print-settings, and deadline"
|
|
1622
3066
|
},
|
|
1623
3067
|
subCommands: {
|
|
1624
3068
|
gather: gatherCmd,
|
|
@@ -1626,10 +3070,17 @@ var main = defineCommand({
|
|
|
1626
3070
|
inline: inlineCmd,
|
|
1627
3071
|
post: postCmd,
|
|
1628
3072
|
cost: costCmd,
|
|
3073
|
+
"check-cost": checkCostCmd,
|
|
1629
3074
|
validate: validateCmd,
|
|
3075
|
+
"seed-draft": seedDraftCmd,
|
|
1630
3076
|
adapt: adaptCmd,
|
|
1631
3077
|
extract: extractCmd,
|
|
1632
|
-
"
|
|
3078
|
+
"validate-patches": validatePatchesCmd,
|
|
3079
|
+
"print-schema": printSchemaCmd,
|
|
3080
|
+
"stop-gate": stopGateCmd,
|
|
3081
|
+
"budget-hook": budgetHookCmd,
|
|
3082
|
+
"print-settings": printSettingsCmd,
|
|
3083
|
+
deadline: deadlineCmd
|
|
1633
3084
|
}
|
|
1634
3085
|
});
|
|
1635
3086
|
if (!process.env["VITEST"]) {
|