@jphutchins/code-review 0.1.0-alpha.6 → 0.1.0-alpha.8
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 +12 -10
- package/dist/index.js +445 -215
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/schema/VERSIONING.md +2 -1
- package/schema/findings.schema.json +23 -14
- package/templates/comment.eta +24 -6
- package/templates/inline.eta +16 -9
package/dist/index.js
CHANGED
|
@@ -53,15 +53,146 @@ 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 parseHunk = (patch) => {
|
|
93
|
+
const rawLines = patch.split("\n");
|
|
94
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
95
|
+
const headerHits = lines.reduce(
|
|
96
|
+
(acc, line, index) => {
|
|
97
|
+
const oldStart = hunkOldStart(line);
|
|
98
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
99
|
+
},
|
|
100
|
+
[]
|
|
101
|
+
);
|
|
102
|
+
if (headerHits.length !== 1) {
|
|
103
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
104
|
+
}
|
|
105
|
+
const hit = headerHits[0];
|
|
106
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
107
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
108
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
109
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
110
|
+
const body = classified.filter((line) => line !== null);
|
|
111
|
+
return { kind: "ok", oldStart: hit.oldStart, body };
|
|
112
|
+
};
|
|
113
|
+
var validatePatch = (patch, fileLines) => {
|
|
114
|
+
const parsed = parseHunk(patch);
|
|
115
|
+
if (parsed.kind === "drop") return parsed;
|
|
116
|
+
const { oldStart, body } = parsed;
|
|
117
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
118
|
+
const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
|
|
119
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
120
|
+
if (!oldSideMatches) {
|
|
121
|
+
return drop(
|
|
122
|
+
`patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
126
|
+
return drop("change is not a single contiguous block");
|
|
127
|
+
}
|
|
128
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
129
|
+
const addedCount = body.filter((l) => l.kind === "added").length;
|
|
130
|
+
if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
|
|
131
|
+
if (removedCount === 0) return drop("pure insertion has no range to anchor a suggestion");
|
|
132
|
+
const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
|
|
133
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
134
|
+
return { startLine: firstRemoved, endLine: lastRemoved };
|
|
135
|
+
};
|
|
136
|
+
var patchToSuggestion = (patch) => {
|
|
137
|
+
const parsed = parseHunk(patch);
|
|
138
|
+
if (parsed.kind === "drop") return parsed;
|
|
139
|
+
const { body } = parsed;
|
|
140
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
141
|
+
return drop("change is not a single contiguous block");
|
|
142
|
+
}
|
|
143
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
144
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
145
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
146
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
147
|
+
return addedLines.map((l) => l.text).join("\n");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/surface.ts
|
|
151
|
+
var severityEmoji = (s) => {
|
|
152
|
+
switch (s) {
|
|
153
|
+
case "critical":
|
|
154
|
+
return "\u{1F534}";
|
|
155
|
+
case "major":
|
|
156
|
+
return "\u{1F7E0}";
|
|
157
|
+
case "minor":
|
|
158
|
+
return "\u{1F535}";
|
|
159
|
+
case "nit":
|
|
160
|
+
return "\u26AA";
|
|
161
|
+
default:
|
|
162
|
+
return "\u2753";
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var EMBED_LIMIT = 4e4;
|
|
166
|
+
var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
|
|
167
|
+
var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => {
|
|
168
|
+
const b64 = Buffer.from(JSON.stringify(findings), "utf-8").toString("base64");
|
|
169
|
+
const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
|
|
170
|
+
return marker ? `${AGENTS_STOP_DIRECTIVE}
|
|
171
|
+
${marker}` : "";
|
|
172
|
+
};
|
|
173
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
174
|
+
var projectPatch = (patch) => {
|
|
175
|
+
if (patch === void 0) return { kind: "none" };
|
|
176
|
+
const lowered = patchToSuggestion(patch);
|
|
177
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
178
|
+
};
|
|
179
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
180
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
181
|
+
const sha7 = headSha.slice(0, 7);
|
|
182
|
+
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.`;
|
|
183
|
+
return marker ? `${marker}
|
|
184
|
+
|
|
185
|
+
${linkLine}` : linkLine;
|
|
186
|
+
};
|
|
187
|
+
|
|
56
188
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
189
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
190
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
191
|
var sanitizeFinding = (f) => ({
|
|
61
192
|
...f,
|
|
62
193
|
title: escapePipes(f.title),
|
|
63
194
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
195
|
+
patchProjection: projectPatch(f.patch)
|
|
65
196
|
});
|
|
66
197
|
var emptySeverityCounts = () => ({
|
|
67
198
|
critical: 0,
|
|
@@ -73,35 +204,37 @@ var computeSeverityCounts = (findings) => findings.reduce(
|
|
|
73
204
|
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
74
205
|
emptySeverityCounts()
|
|
75
206
|
);
|
|
76
|
-
var EMBED_LIMIT = 4e4;
|
|
77
207
|
var render = (input) => {
|
|
78
208
|
const eta = new Eta({ autoTrim: false });
|
|
79
209
|
const usageAvailable = input.envelope !== null;
|
|
80
210
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
211
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
81
212
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
82
213
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
83
214
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
84
|
-
const findingsB64 = Buffer.from(JSON.stringify(input.findings), "utf-8").toString("base64");
|
|
85
|
-
const embeddedFindings = findingsB64.length <= EMBED_LIMIT ? findingsB64 : null;
|
|
86
215
|
return eta.renderString(input.template, {
|
|
87
216
|
findings: input.findings,
|
|
88
217
|
envelope: input.envelope,
|
|
89
218
|
usageAvailable,
|
|
90
219
|
costReport,
|
|
220
|
+
pricesProvided,
|
|
91
221
|
route,
|
|
92
222
|
effort,
|
|
93
223
|
modelNames,
|
|
94
224
|
testReport: input.testReport ?? null,
|
|
95
225
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
226
|
+
postedAt: input.postedAt ?? "",
|
|
96
227
|
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
97
228
|
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
98
229
|
inlineDisposition: input.inlineDisposition ?? null,
|
|
99
230
|
runUrl: input.runUrl ?? null,
|
|
100
231
|
jsonUrl: input.jsonUrl ?? null,
|
|
101
|
-
|
|
232
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
102
233
|
reviewUrl: input.reviewUrl ?? null,
|
|
103
234
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
104
|
-
|
|
235
|
+
// Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
|
|
236
|
+
// real tokens spent, we simply have no rates to price them (SPEC §6.2).
|
|
237
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
105
238
|
formatDuration: (ms) => {
|
|
106
239
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
107
240
|
const s = Math.round(ms / 1e3);
|
|
@@ -119,20 +252,8 @@ var render = (input) => {
|
|
|
119
252
|
return `\u2753 ${v}`;
|
|
120
253
|
}
|
|
121
254
|
},
|
|
122
|
-
severityEmoji
|
|
123
|
-
|
|
124
|
-
case "critical":
|
|
125
|
-
return "\u{1F534}";
|
|
126
|
-
case "major":
|
|
127
|
-
return "\u{1F7E0}";
|
|
128
|
-
case "minor":
|
|
129
|
-
return "\u{1F535}";
|
|
130
|
-
case "nit":
|
|
131
|
-
return "\u26AA";
|
|
132
|
-
default:
|
|
133
|
-
return "\u2753";
|
|
134
|
-
}
|
|
135
|
-
}
|
|
255
|
+
severityEmoji,
|
|
256
|
+
formatConfidence
|
|
136
257
|
});
|
|
137
258
|
};
|
|
138
259
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -189,56 +310,33 @@ var partitionFindings = (findings, index) => {
|
|
|
189
310
|
};
|
|
190
311
|
|
|
191
312
|
// src/inline.ts
|
|
192
|
-
var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
|
|
193
|
-
var severityEmoji = (s) => {
|
|
194
|
-
switch (s) {
|
|
195
|
-
case "critical":
|
|
196
|
-
return "\u{1F534}";
|
|
197
|
-
case "major":
|
|
198
|
-
return "\u{1F7E0}";
|
|
199
|
-
case "minor":
|
|
200
|
-
return "\u{1F535}";
|
|
201
|
-
case "nit":
|
|
202
|
-
return "\u26AA";
|
|
203
|
-
default:
|
|
204
|
-
return "\u2753";
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
var severityHeader = (f) => `${severityEmoji(f.severity)} **${f.severity}** \u2014 ${f.title}`;
|
|
208
|
-
var jsonUrlMarker = (jsonUrl) => jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : void 0;
|
|
209
313
|
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
210
|
-
var
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const safe = escapeBackticks2(f.suggestion);
|
|
215
|
-
parts.push(`\`\`\`suggestion
|
|
216
|
-
${safe}
|
|
217
|
-
\`\`\``);
|
|
218
|
-
}
|
|
219
|
-
return parts.join("\n\n");
|
|
220
|
-
};
|
|
221
|
-
var renderCommentBody = (f, eta, template, modelsText, jsonUrl) => {
|
|
222
|
-
return eta.renderString(template, {
|
|
314
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
315
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
316
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
317
|
+
eta.renderString(template, {
|
|
223
318
|
...f,
|
|
224
|
-
|
|
319
|
+
patchProjection: projectPatch(f.patch),
|
|
225
320
|
severityEmoji,
|
|
321
|
+
formatConfidence,
|
|
226
322
|
modelsText,
|
|
227
|
-
jsonUrl: jsonUrl ?? null
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
323
|
+
jsonUrl: jsonUrl ?? null,
|
|
324
|
+
findingsPointer: pointer
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
328
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
232
329
|
const index = indexDiff(diff);
|
|
233
330
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
234
|
-
const eta =
|
|
331
|
+
const eta = new Eta({ autoTrim: false });
|
|
235
332
|
const modelsText = formatModels(models);
|
|
333
|
+
const pointer = context.findingsPointer ?? (fullFindings ? findingsPointer(fullFindings, jsonUrl) : "");
|
|
236
334
|
const comments = inDiff.map((f) => {
|
|
237
335
|
const comment = {
|
|
238
336
|
path: f.path,
|
|
239
337
|
line: f.end_line,
|
|
240
338
|
side: defaultSide(f.side),
|
|
241
|
-
body:
|
|
339
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
242
340
|
};
|
|
243
341
|
if (f.start_line < f.end_line) {
|
|
244
342
|
return {
|
|
@@ -292,15 +390,15 @@ var FindingShape = t.intersection([
|
|
|
292
390
|
end_line: LineNumber,
|
|
293
391
|
severity: SeverityCodec,
|
|
294
392
|
title: t.string,
|
|
295
|
-
|
|
393
|
+
description: t.string,
|
|
394
|
+
reasoning: t.string,
|
|
395
|
+
confidence: Confidence
|
|
296
396
|
}),
|
|
297
397
|
t.partial({
|
|
298
398
|
side: SideCodec,
|
|
299
|
-
suggestion: t.union([t.string, t.null]),
|
|
300
|
-
confidence: Confidence,
|
|
301
399
|
code: t.string,
|
|
302
400
|
code_url: t.string,
|
|
303
|
-
|
|
401
|
+
recommendation: t.string,
|
|
304
402
|
patch: t.string
|
|
305
403
|
})
|
|
306
404
|
]);
|
|
@@ -377,7 +475,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
377
475
|
failures: t.array(TestFailureCodec)
|
|
378
476
|
})
|
|
379
477
|
]);
|
|
380
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
478
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
479
|
+
var noticeFindings = (summary) => ({
|
|
480
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
481
|
+
summary,
|
|
482
|
+
verdict: "comment",
|
|
483
|
+
findings: []
|
|
484
|
+
});
|
|
381
485
|
|
|
382
486
|
// src/validate.ts
|
|
383
487
|
var addFormats = _addFormats;
|
|
@@ -435,23 +539,17 @@ var formatMarkdown = (md) => {
|
|
|
435
539
|
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
436
540
|
`;
|
|
437
541
|
};
|
|
542
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
543
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
438
544
|
var identity = (decoded) => decoded;
|
|
439
545
|
var findingsTable = [
|
|
440
546
|
{
|
|
441
|
-
minor: "0.
|
|
547
|
+
minor: "0.4",
|
|
442
548
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
443
549
|
schemaFile: "findings.schema.json",
|
|
444
550
|
codec: FindingsCodec,
|
|
445
551
|
normalize: identity,
|
|
446
552
|
latest: true
|
|
447
|
-
},
|
|
448
|
-
{
|
|
449
|
-
minor: "0.2",
|
|
450
|
-
defaultVersion: "0.2.0",
|
|
451
|
-
schemaFile: "v0.2/findings.schema.json",
|
|
452
|
-
codec: FindingsCodec,
|
|
453
|
-
normalize: identity,
|
|
454
|
-
latest: false
|
|
455
553
|
}
|
|
456
554
|
];
|
|
457
555
|
var triageTable = [
|
|
@@ -590,12 +688,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
590
688
|
});
|
|
591
689
|
return { comments: adjusted, longFiles };
|
|
592
690
|
};
|
|
593
|
-
var noticeFindings = (message) => ({
|
|
594
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
595
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
596
|
-
verdict: "comment",
|
|
597
|
-
findings: []
|
|
598
|
-
});
|
|
599
691
|
var loadFindings = (path) => {
|
|
600
692
|
let raw;
|
|
601
693
|
try {
|
|
@@ -665,9 +757,8 @@ var parseHtmlUrl = (raw) => {
|
|
|
665
757
|
return void 0;
|
|
666
758
|
}
|
|
667
759
|
};
|
|
668
|
-
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, ghApi) => {
|
|
669
|
-
const
|
|
670
|
-
const pointer = 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.`;
|
|
760
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
|
|
761
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
671
762
|
const body = JSON.stringify({
|
|
672
763
|
body: pointer,
|
|
673
764
|
commit_id: headSha,
|
|
@@ -807,18 +898,20 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
807
898
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
808
899
|
}
|
|
809
900
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
810
|
-
const inlineTemplate =
|
|
901
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
811
902
|
const renderNotice = (message) => formatMarkdown(
|
|
812
903
|
render({
|
|
813
|
-
findings: noticeFindings(message),
|
|
904
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
814
905
|
envelope: null,
|
|
815
906
|
prices: decodedPrices.right,
|
|
907
|
+
pricesProvided: input.pricesProvided,
|
|
816
908
|
template,
|
|
817
909
|
route: input.route,
|
|
818
910
|
reviewedSha: input.headSha,
|
|
819
911
|
effort: input.effort,
|
|
820
912
|
runUrl: input.runUrl,
|
|
821
|
-
jsonUrl: input.jsonUrl
|
|
913
|
+
jsonUrl: input.jsonUrl,
|
|
914
|
+
postedAt: input.postedAt
|
|
822
915
|
})
|
|
823
916
|
);
|
|
824
917
|
if (isEmptyDiff(diff)) {
|
|
@@ -851,6 +944,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
851
944
|
findings,
|
|
852
945
|
envelope: null,
|
|
853
946
|
prices: decodedPrices.right,
|
|
947
|
+
pricesProvided: input.pricesProvided,
|
|
854
948
|
template,
|
|
855
949
|
route: input.route,
|
|
856
950
|
reviewedSha: input.headSha,
|
|
@@ -858,7 +952,8 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
858
952
|
testReport,
|
|
859
953
|
inlineDisposition: { kind: "no-envelope" },
|
|
860
954
|
runUrl: input.runUrl,
|
|
861
|
-
jsonUrl: input.jsonUrl
|
|
955
|
+
jsonUrl: input.jsonUrl,
|
|
956
|
+
postedAt: input.postedAt
|
|
862
957
|
})
|
|
863
958
|
);
|
|
864
959
|
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
@@ -867,10 +962,11 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
867
962
|
);
|
|
868
963
|
process.exit(0);
|
|
869
964
|
}
|
|
965
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
870
966
|
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
871
967
|
inlineTemplate,
|
|
872
968
|
models: envelope.models.map((m) => m.model),
|
|
873
|
-
|
|
969
|
+
findingsPointer: findingsMarker
|
|
874
970
|
});
|
|
875
971
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
876
972
|
for (const wf of longFiles) {
|
|
@@ -881,11 +977,12 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
881
977
|
}
|
|
882
978
|
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
883
979
|
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
884
|
-
const
|
|
980
|
+
const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
885
981
|
const commonRenderInput = {
|
|
886
982
|
findings,
|
|
887
983
|
envelope,
|
|
888
984
|
prices: decodedPrices.right,
|
|
985
|
+
pricesProvided: input.pricesProvided,
|
|
889
986
|
template,
|
|
890
987
|
route: input.route,
|
|
891
988
|
reviewedSha: input.headSha,
|
|
@@ -893,9 +990,10 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
893
990
|
testReport,
|
|
894
991
|
severityCounts: computeSeverityCounts(findings.findings),
|
|
895
992
|
strays,
|
|
896
|
-
inlineDisposition,
|
|
897
993
|
runUrl: input.runUrl,
|
|
898
|
-
jsonUrl: input.jsonUrl
|
|
994
|
+
jsonUrl: input.jsonUrl,
|
|
995
|
+
findingsPointer: findingsMarker,
|
|
996
|
+
postedAt: input.postedAt
|
|
899
997
|
};
|
|
900
998
|
const longFilesNote = longFiles.length > 0 ? `
|
|
901
999
|
|
|
@@ -903,8 +1001,14 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
903
1001
|
|
|
904
1002
|
> **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.
|
|
905
1003
|
` : "";
|
|
906
|
-
const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
907
|
-
const stickyRef = await upsertSticky(
|
|
1004
|
+
const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
1005
|
+
const stickyRef = await upsertSticky(
|
|
1006
|
+
input.repo,
|
|
1007
|
+
prNumber,
|
|
1008
|
+
existingSticky,
|
|
1009
|
+
renderBody(initialDisposition),
|
|
1010
|
+
ghApi
|
|
1011
|
+
);
|
|
908
1012
|
if (comments.length === 0) return;
|
|
909
1013
|
if (alreadyReviewedThisSha) {
|
|
910
1014
|
process.stderr.write(
|
|
@@ -923,15 +1027,26 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
923
1027
|
input.headSha,
|
|
924
1028
|
comments,
|
|
925
1029
|
stickyRef?.url,
|
|
1030
|
+
findingsMarker,
|
|
926
1031
|
ghApi
|
|
927
1032
|
);
|
|
928
1033
|
process.stderr.write(
|
|
929
1034
|
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
930
1035
|
`
|
|
931
1036
|
);
|
|
932
|
-
if (stickyRef !== null
|
|
1037
|
+
if (stickyRef !== null) {
|
|
1038
|
+
const confirmedDisposition = {
|
|
1039
|
+
kind: "posted",
|
|
1040
|
+
count: comments.length,
|
|
1041
|
+
sha: input.headSha
|
|
1042
|
+
};
|
|
933
1043
|
try {
|
|
934
|
-
await patchComment(
|
|
1044
|
+
await patchComment(
|
|
1045
|
+
input.repo,
|
|
1046
|
+
stickyRef.id,
|
|
1047
|
+
renderBody(confirmedDisposition, reviewUrl),
|
|
1048
|
+
ghApi
|
|
1049
|
+
);
|
|
935
1050
|
process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
|
|
936
1051
|
`);
|
|
937
1052
|
} catch (err) {
|
|
@@ -1263,24 +1378,42 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1263
1378
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1264
1379
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1265
1380
|
}));
|
|
1381
|
+
var findingsOutcome = (native, agentFilePath) => {
|
|
1382
|
+
const ladder = extractStructured({ kind: "findings", native, agentFilePath });
|
|
1383
|
+
if (ladder.kind !== "ok")
|
|
1384
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
1385
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
1386
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
1387
|
+
kind: "telemetry-only",
|
|
1388
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
1389
|
+
};
|
|
1390
|
+
};
|
|
1266
1391
|
var adaptClaudeCode = (native, agentFilePath, meta) => {
|
|
1267
|
-
const
|
|
1268
|
-
if (outcome.kind !== "ok") {
|
|
1269
|
-
return left(describeLadderFailure(outcome));
|
|
1270
|
-
}
|
|
1271
|
-
const resolution = resolve("findings", outcome.candidate);
|
|
1272
|
-
return resolution.kind === "ok" ? right({
|
|
1273
|
-
schema_version: resolution.version,
|
|
1274
|
-
findings: resolution.value,
|
|
1392
|
+
const telemetry = {
|
|
1275
1393
|
models: mapModelUsage(native.modelUsage),
|
|
1276
1394
|
turns: native.num_turns,
|
|
1277
1395
|
duration_ms: native.duration_ms,
|
|
1278
1396
|
vendor_cost_usd: native.total_cost_usd ?? null,
|
|
1279
1397
|
...meta.route ? { route: meta.route } : {},
|
|
1280
1398
|
...meta.effort ? { effort: meta.effort } : {}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
)
|
|
1399
|
+
};
|
|
1400
|
+
const outcome = findingsOutcome(native, agentFilePath);
|
|
1401
|
+
switch (outcome.kind) {
|
|
1402
|
+
case "ok":
|
|
1403
|
+
return right({
|
|
1404
|
+
schema_version: outcome.version,
|
|
1405
|
+
findings: outcome.findings,
|
|
1406
|
+
...telemetry
|
|
1407
|
+
});
|
|
1408
|
+
case "telemetry-only":
|
|
1409
|
+
return right({
|
|
1410
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1411
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
1412
|
+
|
|
1413
|
+
${outcome.reason}`),
|
|
1414
|
+
...telemetry
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1284
1417
|
};
|
|
1285
1418
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1286
1419
|
switch (adapterName) {
|
|
@@ -1294,83 +1427,86 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
|
1294
1427
|
}
|
|
1295
1428
|
}
|
|
1296
1429
|
};
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
1308
|
-
return null;
|
|
1309
|
-
};
|
|
1310
|
-
var trimmedMiddle = (body) => {
|
|
1311
|
-
const first = body.findIndex((l) => l.kind !== "context");
|
|
1312
|
-
if (first === -1) return [];
|
|
1313
|
-
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
1314
|
-
return body.slice(first, last + 1);
|
|
1430
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
1431
|
+
switch (state.kind) {
|
|
1432
|
+
case "missing":
|
|
1433
|
+
return `${draftPath} does not exist yet`;
|
|
1434
|
+
case "unreadable":
|
|
1435
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
1436
|
+
case "invalid":
|
|
1437
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
1438
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
1439
|
+
}
|
|
1315
1440
|
};
|
|
1316
|
-
var
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1441
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
1442
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
1443
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
1444
|
+
return {
|
|
1445
|
+
kind: "block",
|
|
1446
|
+
reason: [
|
|
1447
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
1448
|
+
`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.`,
|
|
1449
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
|
|
1450
|
+
].join("\n")
|
|
1451
|
+
};
|
|
1321
1452
|
};
|
|
1322
|
-
var
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
var lowerPatch = (patch, fileLines) => {
|
|
1332
|
-
const rawLines = patch.split("\n");
|
|
1333
|
-
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
1334
|
-
const headerHits = lines.reduce(
|
|
1335
|
-
(acc, line, index) => {
|
|
1336
|
-
const oldStart = hunkOldStart(line);
|
|
1337
|
-
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
1338
|
-
},
|
|
1339
|
-
[]
|
|
1340
|
-
);
|
|
1341
|
-
if (headerHits.length !== 1) {
|
|
1342
|
-
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
1453
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
1454
|
+
let raw;
|
|
1455
|
+
try {
|
|
1456
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1459
|
+
return { kind: "missing" };
|
|
1460
|
+
}
|
|
1461
|
+
return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
|
|
1343
1462
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
1353
|
-
if (!oldSideMatches) {
|
|
1354
|
-
return drop(
|
|
1355
|
-
`patch context does not match the file at lines ${String(hit.oldStart)}..${String(hit.oldStart + oldSideTexts.length - 1)}`
|
|
1356
|
-
);
|
|
1463
|
+
let parsed;
|
|
1464
|
+
try {
|
|
1465
|
+
parsed = JSON.parse(raw);
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
return {
|
|
1468
|
+
kind: "invalid",
|
|
1469
|
+
errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
|
|
1470
|
+
};
|
|
1357
1471
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1472
|
+
let schemaPath;
|
|
1473
|
+
try {
|
|
1474
|
+
schemaPath = resolveSchema(parsed);
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
1477
|
+
}
|
|
1478
|
+
try {
|
|
1479
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
1480
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
1360
1483
|
}
|
|
1361
|
-
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
1362
|
-
const addedLines = body.filter((l) => l.kind === "added");
|
|
1363
|
-
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
1364
|
-
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
1365
|
-
const { firstRemoved, lastRemoved } = removedRange(body, hit.oldStart);
|
|
1366
|
-
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
1367
|
-
return {
|
|
1368
|
-
kind: "ok",
|
|
1369
|
-
startLine: firstRemoved,
|
|
1370
|
-
endLine: lastRemoved,
|
|
1371
|
-
suggestion: addedLines.map((l) => l.text).join("\n")
|
|
1372
|
-
};
|
|
1373
1484
|
};
|
|
1485
|
+
var readNudges = (counterPath) => {
|
|
1486
|
+
try {
|
|
1487
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
1488
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
1489
|
+
} catch {
|
|
1490
|
+
return 0;
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
var bumpNudges = (counterPath, current) => {
|
|
1494
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
1495
|
+
`);
|
|
1496
|
+
};
|
|
1497
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1498
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
1499
|
+
"code-review stop-gate --draft",
|
|
1500
|
+
shellQuote(draftPath),
|
|
1501
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
1502
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
1503
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
1504
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
1505
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
1506
|
+
].join(" ");
|
|
1507
|
+
var stopHookSettings = (command) => ({
|
|
1508
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
1509
|
+
});
|
|
1374
1510
|
|
|
1375
1511
|
// src/index.ts
|
|
1376
1512
|
var readJSON = (path) => {
|
|
@@ -1406,12 +1542,13 @@ var unwrapAdapt = (either) => {
|
|
|
1406
1542
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1407
1543
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1408
1544
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1409
|
-
var
|
|
1410
|
-
|
|
1545
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
1546
|
+
var resolvePrices = (pricesArg) => {
|
|
1547
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1411
1548
|
process.stderr.write(
|
|
1412
|
-
"code-review: no --prices given \u2014
|
|
1549
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1413
1550
|
);
|
|
1414
|
-
return bundledPath("schema", "prices.example.json");
|
|
1551
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1415
1552
|
};
|
|
1416
1553
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1417
1554
|
var renderCmd = defineCommand({
|
|
@@ -1459,19 +1596,21 @@ var renderCmd = defineCommand({
|
|
|
1459
1596
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1460
1597
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1461
1598
|
const templatePath = resolveTemplatePath(args.template);
|
|
1462
|
-
const
|
|
1463
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
1599
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1600
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1464
1601
|
const template = readFileSync(templatePath, "utf-8");
|
|
1465
1602
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1466
1603
|
const output = render({
|
|
1467
1604
|
findings,
|
|
1468
1605
|
envelope,
|
|
1469
1606
|
prices,
|
|
1607
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1470
1608
|
template,
|
|
1471
1609
|
reviewedSha: args["reviewed-sha"],
|
|
1472
1610
|
route: args.route,
|
|
1473
1611
|
effort: args.effort,
|
|
1474
|
-
testReport
|
|
1612
|
+
testReport,
|
|
1613
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1475
1614
|
});
|
|
1476
1615
|
process.stdout.write(output);
|
|
1477
1616
|
}
|
|
@@ -1494,14 +1633,17 @@ var inlineCmd = defineCommand({
|
|
|
1494
1633
|
},
|
|
1495
1634
|
template: {
|
|
1496
1635
|
type: "string",
|
|
1497
|
-
description: "Path to inline comment Eta template (default:
|
|
1636
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1498
1637
|
}
|
|
1499
1638
|
},
|
|
1500
1639
|
run: async ({ args }) => {
|
|
1501
1640
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1502
1641
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1503
|
-
const inlineTemplate =
|
|
1504
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
1642
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
1643
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
1644
|
+
inlineTemplate,
|
|
1645
|
+
findings
|
|
1646
|
+
});
|
|
1505
1647
|
process.stdout.write(
|
|
1506
1648
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1507
1649
|
);
|
|
@@ -1531,8 +1673,7 @@ var costCmd = defineCommand({
|
|
|
1531
1673
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1532
1674
|
}
|
|
1533
1675
|
});
|
|
1534
|
-
var
|
|
1535
|
-
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1676
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
1536
1677
|
var validateCmd = defineCommand({
|
|
1537
1678
|
meta: {
|
|
1538
1679
|
name: "validate",
|
|
@@ -1684,36 +1825,30 @@ var readFileLines = (path) => {
|
|
|
1684
1825
|
return null;
|
|
1685
1826
|
}
|
|
1686
1827
|
};
|
|
1687
|
-
var
|
|
1828
|
+
var validateFinding = (finding, repoRoot) => {
|
|
1688
1829
|
if (finding.patch === void 0) return finding;
|
|
1689
|
-
const base = withoutPatch(finding);
|
|
1690
1830
|
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
1691
1831
|
if (lines === null) {
|
|
1692
1832
|
process.stderr.write(
|
|
1693
|
-
`
|
|
1833
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
1694
1834
|
`
|
|
1695
1835
|
);
|
|
1696
|
-
return
|
|
1836
|
+
return withoutPatch(finding);
|
|
1697
1837
|
}
|
|
1698
|
-
const result =
|
|
1699
|
-
if (
|
|
1838
|
+
const result = validatePatch(finding.patch, lines);
|
|
1839
|
+
if ("kind" in result) {
|
|
1700
1840
|
process.stderr.write(
|
|
1701
|
-
`
|
|
1841
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
1702
1842
|
`
|
|
1703
1843
|
);
|
|
1704
|
-
return
|
|
1844
|
+
return withoutPatch(finding);
|
|
1705
1845
|
}
|
|
1706
|
-
return {
|
|
1707
|
-
...base,
|
|
1708
|
-
suggestion: result.suggestion,
|
|
1709
|
-
start_line: result.startLine,
|
|
1710
|
-
end_line: result.endLine
|
|
1711
|
-
};
|
|
1846
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
1712
1847
|
};
|
|
1713
|
-
var
|
|
1848
|
+
var validatePatchesCmd = defineCommand({
|
|
1714
1849
|
meta: {
|
|
1715
|
-
name: "
|
|
1716
|
-
description: "Validate each finding's patch against the real PR-head tree
|
|
1850
|
+
name: "validate-patches",
|
|
1851
|
+
description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range to it and keeping the patch, or dropping the patch (issue #10)"
|
|
1717
1852
|
},
|
|
1718
1853
|
args: {
|
|
1719
1854
|
findings: {
|
|
@@ -1729,11 +1864,11 @@ var lowerSuggestionsCmd = defineCommand({
|
|
|
1729
1864
|
run: async ({ args }) => {
|
|
1730
1865
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1731
1866
|
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
1732
|
-
const
|
|
1867
|
+
const validated = {
|
|
1733
1868
|
...findings,
|
|
1734
|
-
findings: findings.findings.map((f) =>
|
|
1869
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
1735
1870
|
};
|
|
1736
|
-
process.stdout.write(`${JSON.stringify(
|
|
1871
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
1737
1872
|
`);
|
|
1738
1873
|
}
|
|
1739
1874
|
});
|
|
@@ -1783,6 +1918,97 @@ var printSchemaCmd = defineCommand({
|
|
|
1783
1918
|
`);
|
|
1784
1919
|
}
|
|
1785
1920
|
});
|
|
1921
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
1922
|
+
var drainStdin = () => {
|
|
1923
|
+
if (process.stdin.isTTY) return;
|
|
1924
|
+
try {
|
|
1925
|
+
readFileSync(0);
|
|
1926
|
+
} catch {
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
var requireMaxNudges = (raw) => {
|
|
1930
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
1931
|
+
if (!/^\d+$/.test(raw)) {
|
|
1932
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
1933
|
+
}
|
|
1934
|
+
const n = Number.parseInt(raw, 10);
|
|
1935
|
+
if (n < 1) {
|
|
1936
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
1937
|
+
}
|
|
1938
|
+
return n;
|
|
1939
|
+
};
|
|
1940
|
+
var stopGateCmd = defineCommand({
|
|
1941
|
+
meta: {
|
|
1942
|
+
name: "stop-gate",
|
|
1943
|
+
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."
|
|
1944
|
+
},
|
|
1945
|
+
args: {
|
|
1946
|
+
draft: {
|
|
1947
|
+
type: "string",
|
|
1948
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
1949
|
+
required: true
|
|
1950
|
+
},
|
|
1951
|
+
kind: {
|
|
1952
|
+
type: "string",
|
|
1953
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
1954
|
+
},
|
|
1955
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
1956
|
+
"schema-version": {
|
|
1957
|
+
type: "string",
|
|
1958
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
1959
|
+
},
|
|
1960
|
+
"max-nudges": {
|
|
1961
|
+
type: "string",
|
|
1962
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
1963
|
+
},
|
|
1964
|
+
counter: {
|
|
1965
|
+
type: "string",
|
|
1966
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
1967
|
+
},
|
|
1968
|
+
"print-settings": {
|
|
1969
|
+
type: "boolean",
|
|
1970
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
run: async ({ args }) => {
|
|
1974
|
+
const draftPath = resolve$1(args.draft);
|
|
1975
|
+
if (args["print-settings"]) {
|
|
1976
|
+
const command = defaultHookCommand(draftPath, {
|
|
1977
|
+
kind: args.kind,
|
|
1978
|
+
schema: args.schema,
|
|
1979
|
+
schemaVersion: args["schema-version"],
|
|
1980
|
+
maxNudges: args["max-nudges"],
|
|
1981
|
+
counter: args.counter
|
|
1982
|
+
});
|
|
1983
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
1984
|
+
`);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
drainStdin();
|
|
1988
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
1989
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
1990
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
1991
|
+
const state = draftState(
|
|
1992
|
+
draftPath,
|
|
1993
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
1994
|
+
);
|
|
1995
|
+
const nudges = readNudges(counterPath);
|
|
1996
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
1997
|
+
if (decision.kind === "block") {
|
|
1998
|
+
try {
|
|
1999
|
+
bumpNudges(counterPath, nudges);
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
process.stderr.write(
|
|
2002
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
|
|
2003
|
+
`
|
|
2004
|
+
);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
2008
|
+
`);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
1786
2012
|
var gatherCmd = defineCommand({
|
|
1787
2013
|
meta: {
|
|
1788
2014
|
name: "gather",
|
|
@@ -1867,7 +2093,7 @@ var postCmd = defineCommand({
|
|
|
1867
2093
|
},
|
|
1868
2094
|
"inline-template": {
|
|
1869
2095
|
type: "string",
|
|
1870
|
-
description: "Path to inline comment Eta template (default:
|
|
2096
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1871
2097
|
},
|
|
1872
2098
|
route: {
|
|
1873
2099
|
type: "string",
|
|
@@ -1899,21 +2125,24 @@ var postCmd = defineCommand({
|
|
|
1899
2125
|
}
|
|
1900
2126
|
},
|
|
1901
2127
|
run: async ({ args }) => {
|
|
2128
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1902
2129
|
await post({
|
|
1903
2130
|
repo: args.repo,
|
|
1904
2131
|
headSha: args["head-sha"],
|
|
1905
2132
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1906
2133
|
findingsPath: args.findings,
|
|
1907
2134
|
envelopePath: args.usage,
|
|
1908
|
-
pricesPath:
|
|
2135
|
+
pricesPath: priceResolution.path,
|
|
2136
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1909
2137
|
templatePath: resolveTemplatePath(args.template),
|
|
1910
|
-
inlineTemplatePath:
|
|
2138
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1911
2139
|
route: args.route,
|
|
1912
2140
|
headBranch: args["head-branch"],
|
|
1913
2141
|
effort: args.effort,
|
|
1914
2142
|
testReportPath: args["test-report"],
|
|
1915
2143
|
runUrl: args["run-url"],
|
|
1916
|
-
jsonUrl: args["json-url"]
|
|
2144
|
+
jsonUrl: args["json-url"],
|
|
2145
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1917
2146
|
});
|
|
1918
2147
|
}
|
|
1919
2148
|
});
|
|
@@ -1921,7 +2150,7 @@ var main = defineCommand({
|
|
|
1921
2150
|
meta: {
|
|
1922
2151
|
name: "code-review",
|
|
1923
2152
|
version: packageVersion,
|
|
1924
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract,
|
|
2153
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, validate, and stop-gate findings JSON"
|
|
1925
2154
|
},
|
|
1926
2155
|
subCommands: {
|
|
1927
2156
|
gather: gatherCmd,
|
|
@@ -1932,8 +2161,9 @@ var main = defineCommand({
|
|
|
1932
2161
|
validate: validateCmd,
|
|
1933
2162
|
adapt: adaptCmd,
|
|
1934
2163
|
extract: extractCmd,
|
|
1935
|
-
"
|
|
1936
|
-
"print-schema": printSchemaCmd
|
|
2164
|
+
"validate-patches": validatePatchesCmd,
|
|
2165
|
+
"print-schema": printSchemaCmd,
|
|
2166
|
+
"stop-gate": stopGateCmd
|
|
1937
2167
|
}
|
|
1938
2168
|
});
|
|
1939
2169
|
if (!process.env["VITEST"]) {
|