@jphutchins/code-review 0.1.0-alpha.6 → 0.1.0-alpha.7
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 +1 -1
- package/dist/index.js +428 -212
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/VERSIONING.md +2 -1
- package/schema/findings.schema.json +19 -10
- package/templates/comment.eta +21 -6
- package/templates/inline.eta +15 -8
package/dist/index.js
CHANGED
|
@@ -53,15 +53,138 @@ 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 === null || 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
|
+
|
|
56
180
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
181
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
182
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
183
|
var sanitizeFinding = (f) => ({
|
|
61
184
|
...f,
|
|
62
185
|
title: escapePipes(f.title),
|
|
63
186
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
187
|
+
patchProjection: projectPatch(f.patch)
|
|
65
188
|
});
|
|
66
189
|
var emptySeverityCounts = () => ({
|
|
67
190
|
critical: 0,
|
|
@@ -73,21 +196,20 @@ var computeSeverityCounts = (findings) => findings.reduce(
|
|
|
73
196
|
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
74
197
|
emptySeverityCounts()
|
|
75
198
|
);
|
|
76
|
-
var EMBED_LIMIT = 4e4;
|
|
77
199
|
var render = (input) => {
|
|
78
200
|
const eta = new Eta({ autoTrim: false });
|
|
79
201
|
const usageAvailable = input.envelope !== null;
|
|
80
202
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
203
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
81
204
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
82
205
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
83
206
|
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
207
|
return eta.renderString(input.template, {
|
|
87
208
|
findings: input.findings,
|
|
88
209
|
envelope: input.envelope,
|
|
89
210
|
usageAvailable,
|
|
90
211
|
costReport,
|
|
212
|
+
pricesProvided,
|
|
91
213
|
route,
|
|
92
214
|
effort,
|
|
93
215
|
modelNames,
|
|
@@ -98,10 +220,12 @@ var render = (input) => {
|
|
|
98
220
|
inlineDisposition: input.inlineDisposition ?? null,
|
|
99
221
|
runUrl: input.runUrl ?? null,
|
|
100
222
|
jsonUrl: input.jsonUrl ?? null,
|
|
101
|
-
|
|
223
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
102
224
|
reviewUrl: input.reviewUrl ?? null,
|
|
103
225
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
104
|
-
|
|
226
|
+
// Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
|
|
227
|
+
// real tokens spent, we simply have no rates to price them (SPEC §6.2).
|
|
228
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
105
229
|
formatDuration: (ms) => {
|
|
106
230
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
107
231
|
const s = Math.round(ms / 1e3);
|
|
@@ -119,20 +243,7 @@ var render = (input) => {
|
|
|
119
243
|
return `\u2753 ${v}`;
|
|
120
244
|
}
|
|
121
245
|
},
|
|
122
|
-
severityEmoji
|
|
123
|
-
switch (s) {
|
|
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
|
-
}
|
|
246
|
+
severityEmoji
|
|
136
247
|
});
|
|
137
248
|
};
|
|
138
249
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -189,56 +300,32 @@ var partitionFindings = (findings, index) => {
|
|
|
189
300
|
};
|
|
190
301
|
|
|
191
302
|
// 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
303
|
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, {
|
|
304
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
305
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
307
|
+
eta.renderString(template, {
|
|
223
308
|
...f,
|
|
224
|
-
|
|
309
|
+
patchProjection: projectPatch(f.patch),
|
|
225
310
|
severityEmoji,
|
|
226
311
|
modelsText,
|
|
227
|
-
jsonUrl: jsonUrl ?? null
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
312
|
+
jsonUrl: jsonUrl ?? null,
|
|
313
|
+
findingsPointer: pointer
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
317
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
232
318
|
const index = indexDiff(diff);
|
|
233
319
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
234
|
-
const eta =
|
|
320
|
+
const eta = new Eta({ autoTrim: false });
|
|
235
321
|
const modelsText = formatModels(models);
|
|
322
|
+
const pointer = context.findingsPointer ?? (fullFindings ? findingsPointer(fullFindings, jsonUrl) : "");
|
|
236
323
|
const comments = inDiff.map((f) => {
|
|
237
324
|
const comment = {
|
|
238
325
|
path: f.path,
|
|
239
326
|
line: f.end_line,
|
|
240
327
|
side: defaultSide(f.side),
|
|
241
|
-
body:
|
|
328
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
242
329
|
};
|
|
243
330
|
if (f.start_line < f.end_line) {
|
|
244
331
|
return {
|
|
@@ -292,16 +379,16 @@ var FindingShape = t.intersection([
|
|
|
292
379
|
end_line: LineNumber,
|
|
293
380
|
severity: SeverityCodec,
|
|
294
381
|
title: t.string,
|
|
295
|
-
|
|
382
|
+
description: t.string,
|
|
383
|
+
reasoning: t.string,
|
|
384
|
+
confidence: Confidence
|
|
296
385
|
}),
|
|
297
386
|
t.partial({
|
|
298
387
|
side: SideCodec,
|
|
299
|
-
suggestion: t.union([t.string, t.null]),
|
|
300
|
-
confidence: Confidence,
|
|
301
388
|
code: t.string,
|
|
302
389
|
code_url: t.string,
|
|
303
|
-
|
|
304
|
-
patch: t.string
|
|
390
|
+
recommendation: t.string,
|
|
391
|
+
patch: t.union([t.string, t.null])
|
|
305
392
|
})
|
|
306
393
|
]);
|
|
307
394
|
var EndGeStart = t.refinement(
|
|
@@ -377,7 +464,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
377
464
|
failures: t.array(TestFailureCodec)
|
|
378
465
|
})
|
|
379
466
|
]);
|
|
380
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
467
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
468
|
+
var noticeFindings = (summary) => ({
|
|
469
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
470
|
+
summary,
|
|
471
|
+
verdict: "comment",
|
|
472
|
+
findings: []
|
|
473
|
+
});
|
|
381
474
|
|
|
382
475
|
// src/validate.ts
|
|
383
476
|
var addFormats = _addFormats;
|
|
@@ -438,20 +531,12 @@ var formatMarkdown = (md) => {
|
|
|
438
531
|
var identity = (decoded) => decoded;
|
|
439
532
|
var findingsTable = [
|
|
440
533
|
{
|
|
441
|
-
minor: "0.
|
|
534
|
+
minor: "0.4",
|
|
442
535
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
443
536
|
schemaFile: "findings.schema.json",
|
|
444
537
|
codec: FindingsCodec,
|
|
445
538
|
normalize: identity,
|
|
446
539
|
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
540
|
}
|
|
456
541
|
];
|
|
457
542
|
var triageTable = [
|
|
@@ -590,12 +675,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
590
675
|
});
|
|
591
676
|
return { comments: adjusted, longFiles };
|
|
592
677
|
};
|
|
593
|
-
var noticeFindings = (message) => ({
|
|
594
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
595
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
596
|
-
verdict: "comment",
|
|
597
|
-
findings: []
|
|
598
|
-
});
|
|
599
678
|
var loadFindings = (path) => {
|
|
600
679
|
let raw;
|
|
601
680
|
try {
|
|
@@ -665,9 +744,12 @@ var parseHtmlUrl = (raw) => {
|
|
|
665
744
|
return void 0;
|
|
666
745
|
}
|
|
667
746
|
};
|
|
668
|
-
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, ghApi) => {
|
|
747
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
|
|
669
748
|
const sha7 = headSha.slice(0, 7);
|
|
670
|
-
const
|
|
749
|
+
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.`;
|
|
750
|
+
const pointer = marker ? `${marker}
|
|
751
|
+
|
|
752
|
+
${linkLine}` : linkLine;
|
|
671
753
|
const body = JSON.stringify({
|
|
672
754
|
body: pointer,
|
|
673
755
|
commit_id: headSha,
|
|
@@ -807,12 +889,13 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
807
889
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
808
890
|
}
|
|
809
891
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
810
|
-
const inlineTemplate =
|
|
892
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
811
893
|
const renderNotice = (message) => formatMarkdown(
|
|
812
894
|
render({
|
|
813
|
-
findings: noticeFindings(message),
|
|
895
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
814
896
|
envelope: null,
|
|
815
897
|
prices: decodedPrices.right,
|
|
898
|
+
pricesProvided: input.pricesProvided,
|
|
816
899
|
template,
|
|
817
900
|
route: input.route,
|
|
818
901
|
reviewedSha: input.headSha,
|
|
@@ -851,6 +934,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
851
934
|
findings,
|
|
852
935
|
envelope: null,
|
|
853
936
|
prices: decodedPrices.right,
|
|
937
|
+
pricesProvided: input.pricesProvided,
|
|
854
938
|
template,
|
|
855
939
|
route: input.route,
|
|
856
940
|
reviewedSha: input.headSha,
|
|
@@ -867,10 +951,11 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
867
951
|
);
|
|
868
952
|
process.exit(0);
|
|
869
953
|
}
|
|
954
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
870
955
|
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
871
956
|
inlineTemplate,
|
|
872
957
|
models: envelope.models.map((m) => m.model),
|
|
873
|
-
|
|
958
|
+
findingsPointer: findingsMarker
|
|
874
959
|
});
|
|
875
960
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
876
961
|
for (const wf of longFiles) {
|
|
@@ -881,11 +966,12 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
881
966
|
}
|
|
882
967
|
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
883
968
|
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
884
|
-
const
|
|
969
|
+
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
970
|
const commonRenderInput = {
|
|
886
971
|
findings,
|
|
887
972
|
envelope,
|
|
888
973
|
prices: decodedPrices.right,
|
|
974
|
+
pricesProvided: input.pricesProvided,
|
|
889
975
|
template,
|
|
890
976
|
route: input.route,
|
|
891
977
|
reviewedSha: input.headSha,
|
|
@@ -893,9 +979,9 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
893
979
|
testReport,
|
|
894
980
|
severityCounts: computeSeverityCounts(findings.findings),
|
|
895
981
|
strays,
|
|
896
|
-
inlineDisposition,
|
|
897
982
|
runUrl: input.runUrl,
|
|
898
|
-
jsonUrl: input.jsonUrl
|
|
983
|
+
jsonUrl: input.jsonUrl,
|
|
984
|
+
findingsPointer: findingsMarker
|
|
899
985
|
};
|
|
900
986
|
const longFilesNote = longFiles.length > 0 ? `
|
|
901
987
|
|
|
@@ -903,8 +989,14 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
903
989
|
|
|
904
990
|
> **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
991
|
` : "";
|
|
906
|
-
const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
907
|
-
const stickyRef = await upsertSticky(
|
|
992
|
+
const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
|
|
993
|
+
const stickyRef = await upsertSticky(
|
|
994
|
+
input.repo,
|
|
995
|
+
prNumber,
|
|
996
|
+
existingSticky,
|
|
997
|
+
renderBody(initialDisposition),
|
|
998
|
+
ghApi
|
|
999
|
+
);
|
|
908
1000
|
if (comments.length === 0) return;
|
|
909
1001
|
if (alreadyReviewedThisSha) {
|
|
910
1002
|
process.stderr.write(
|
|
@@ -923,15 +1015,26 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
923
1015
|
input.headSha,
|
|
924
1016
|
comments,
|
|
925
1017
|
stickyRef?.url,
|
|
1018
|
+
findingsMarker,
|
|
926
1019
|
ghApi
|
|
927
1020
|
);
|
|
928
1021
|
process.stderr.write(
|
|
929
1022
|
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
930
1023
|
`
|
|
931
1024
|
);
|
|
932
|
-
if (stickyRef !== null
|
|
1025
|
+
if (stickyRef !== null) {
|
|
1026
|
+
const confirmedDisposition = {
|
|
1027
|
+
kind: "posted",
|
|
1028
|
+
count: comments.length,
|
|
1029
|
+
sha: input.headSha
|
|
1030
|
+
};
|
|
933
1031
|
try {
|
|
934
|
-
await patchComment(
|
|
1032
|
+
await patchComment(
|
|
1033
|
+
input.repo,
|
|
1034
|
+
stickyRef.id,
|
|
1035
|
+
renderBody(confirmedDisposition, reviewUrl),
|
|
1036
|
+
ghApi
|
|
1037
|
+
);
|
|
935
1038
|
process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
|
|
936
1039
|
`);
|
|
937
1040
|
} catch (err) {
|
|
@@ -1263,24 +1366,42 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1263
1366
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1264
1367
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1265
1368
|
}));
|
|
1369
|
+
var findingsOutcome = (native, agentFilePath) => {
|
|
1370
|
+
const ladder = extractStructured({ kind: "findings", native, agentFilePath });
|
|
1371
|
+
if (ladder.kind !== "ok")
|
|
1372
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
1373
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
1374
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
1375
|
+
kind: "telemetry-only",
|
|
1376
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
1377
|
+
};
|
|
1378
|
+
};
|
|
1266
1379
|
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,
|
|
1380
|
+
const telemetry = {
|
|
1275
1381
|
models: mapModelUsage(native.modelUsage),
|
|
1276
1382
|
turns: native.num_turns,
|
|
1277
1383
|
duration_ms: native.duration_ms,
|
|
1278
1384
|
vendor_cost_usd: native.total_cost_usd ?? null,
|
|
1279
1385
|
...meta.route ? { route: meta.route } : {},
|
|
1280
1386
|
...meta.effort ? { effort: meta.effort } : {}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
)
|
|
1387
|
+
};
|
|
1388
|
+
const outcome = findingsOutcome(native, agentFilePath);
|
|
1389
|
+
switch (outcome.kind) {
|
|
1390
|
+
case "ok":
|
|
1391
|
+
return right({
|
|
1392
|
+
schema_version: outcome.version,
|
|
1393
|
+
findings: outcome.findings,
|
|
1394
|
+
...telemetry
|
|
1395
|
+
});
|
|
1396
|
+
case "telemetry-only":
|
|
1397
|
+
return right({
|
|
1398
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1399
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
1400
|
+
|
|
1401
|
+
${outcome.reason}`),
|
|
1402
|
+
...telemetry
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1284
1405
|
};
|
|
1285
1406
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1286
1407
|
switch (adapterName) {
|
|
@@ -1294,83 +1415,86 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
|
1294
1415
|
}
|
|
1295
1416
|
}
|
|
1296
1417
|
};
|
|
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);
|
|
1418
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
1419
|
+
switch (state.kind) {
|
|
1420
|
+
case "missing":
|
|
1421
|
+
return `${draftPath} does not exist yet`;
|
|
1422
|
+
case "unreadable":
|
|
1423
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
1424
|
+
case "invalid":
|
|
1425
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
1426
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
1427
|
+
}
|
|
1315
1428
|
};
|
|
1316
|
-
var
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1429
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
1430
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
1431
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
1432
|
+
return {
|
|
1433
|
+
kind: "block",
|
|
1434
|
+
reason: [
|
|
1435
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
1436
|
+
`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.`,
|
|
1437
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
|
|
1438
|
+
].join("\n")
|
|
1439
|
+
};
|
|
1321
1440
|
};
|
|
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)}`);
|
|
1441
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
1442
|
+
let raw;
|
|
1443
|
+
try {
|
|
1444
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1447
|
+
return { kind: "missing" };
|
|
1448
|
+
}
|
|
1449
|
+
return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
|
|
1343
1450
|
}
|
|
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
|
-
);
|
|
1451
|
+
let parsed;
|
|
1452
|
+
try {
|
|
1453
|
+
parsed = JSON.parse(raw);
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
return {
|
|
1456
|
+
kind: "invalid",
|
|
1457
|
+
errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
|
|
1458
|
+
};
|
|
1357
1459
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1460
|
+
let schemaPath;
|
|
1461
|
+
try {
|
|
1462
|
+
schemaPath = resolveSchema(parsed);
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
1465
|
+
}
|
|
1466
|
+
try {
|
|
1467
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
1468
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
|
|
1360
1471
|
}
|
|
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
1472
|
};
|
|
1473
|
+
var readNudges = (counterPath) => {
|
|
1474
|
+
try {
|
|
1475
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
1476
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
1477
|
+
} catch {
|
|
1478
|
+
return 0;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
var bumpNudges = (counterPath, current) => {
|
|
1482
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
1483
|
+
`);
|
|
1484
|
+
};
|
|
1485
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1486
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
1487
|
+
"code-review stop-gate --draft",
|
|
1488
|
+
shellQuote(draftPath),
|
|
1489
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
1490
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
1491
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
1492
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
1493
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
1494
|
+
].join(" ");
|
|
1495
|
+
var stopHookSettings = (command) => ({
|
|
1496
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
1497
|
+
});
|
|
1374
1498
|
|
|
1375
1499
|
// src/index.ts
|
|
1376
1500
|
var readJSON = (path) => {
|
|
@@ -1406,12 +1530,13 @@ var unwrapAdapt = (either) => {
|
|
|
1406
1530
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1407
1531
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1408
1532
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1409
|
-
var
|
|
1410
|
-
|
|
1533
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
1534
|
+
var resolvePrices = (pricesArg) => {
|
|
1535
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1411
1536
|
process.stderr.write(
|
|
1412
|
-
"code-review: no --prices given \u2014
|
|
1537
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1413
1538
|
);
|
|
1414
|
-
return bundledPath("schema", "prices.example.json");
|
|
1539
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1415
1540
|
};
|
|
1416
1541
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1417
1542
|
var renderCmd = defineCommand({
|
|
@@ -1459,14 +1584,15 @@ var renderCmd = defineCommand({
|
|
|
1459
1584
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1460
1585
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1461
1586
|
const templatePath = resolveTemplatePath(args.template);
|
|
1462
|
-
const
|
|
1463
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
1587
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1588
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1464
1589
|
const template = readFileSync(templatePath, "utf-8");
|
|
1465
1590
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1466
1591
|
const output = render({
|
|
1467
1592
|
findings,
|
|
1468
1593
|
envelope,
|
|
1469
1594
|
prices,
|
|
1595
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1470
1596
|
template,
|
|
1471
1597
|
reviewedSha: args["reviewed-sha"],
|
|
1472
1598
|
route: args.route,
|
|
@@ -1494,14 +1620,17 @@ var inlineCmd = defineCommand({
|
|
|
1494
1620
|
},
|
|
1495
1621
|
template: {
|
|
1496
1622
|
type: "string",
|
|
1497
|
-
description: "Path to inline comment Eta template (default:
|
|
1623
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1498
1624
|
}
|
|
1499
1625
|
},
|
|
1500
1626
|
run: async ({ args }) => {
|
|
1501
1627
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1502
1628
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1503
|
-
const inlineTemplate =
|
|
1504
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
1629
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
1630
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
1631
|
+
inlineTemplate,
|
|
1632
|
+
findings
|
|
1633
|
+
});
|
|
1505
1634
|
process.stdout.write(
|
|
1506
1635
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1507
1636
|
);
|
|
@@ -1531,8 +1660,7 @@ var costCmd = defineCommand({
|
|
|
1531
1660
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1532
1661
|
}
|
|
1533
1662
|
});
|
|
1534
|
-
var
|
|
1535
|
-
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1663
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
1536
1664
|
var validateCmd = defineCommand({
|
|
1537
1665
|
meta: {
|
|
1538
1666
|
name: "validate",
|
|
@@ -1684,36 +1812,30 @@ var readFileLines = (path) => {
|
|
|
1684
1812
|
return null;
|
|
1685
1813
|
}
|
|
1686
1814
|
};
|
|
1687
|
-
var
|
|
1688
|
-
if (finding.patch === void 0) return finding;
|
|
1689
|
-
const base = withoutPatch(finding);
|
|
1815
|
+
var validateFinding = (finding, repoRoot) => {
|
|
1816
|
+
if (finding.patch === void 0 || finding.patch === null) return finding;
|
|
1690
1817
|
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
1691
1818
|
if (lines === null) {
|
|
1692
1819
|
process.stderr.write(
|
|
1693
|
-
`
|
|
1820
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
1694
1821
|
`
|
|
1695
1822
|
);
|
|
1696
|
-
return
|
|
1823
|
+
return withoutPatch(finding);
|
|
1697
1824
|
}
|
|
1698
|
-
const result =
|
|
1699
|
-
if (
|
|
1825
|
+
const result = validatePatch(finding.patch, lines);
|
|
1826
|
+
if ("kind" in result) {
|
|
1700
1827
|
process.stderr.write(
|
|
1701
|
-
`
|
|
1828
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
1702
1829
|
`
|
|
1703
1830
|
);
|
|
1704
|
-
return
|
|
1831
|
+
return withoutPatch(finding);
|
|
1705
1832
|
}
|
|
1706
|
-
return {
|
|
1707
|
-
...base,
|
|
1708
|
-
suggestion: result.suggestion,
|
|
1709
|
-
start_line: result.startLine,
|
|
1710
|
-
end_line: result.endLine
|
|
1711
|
-
};
|
|
1833
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
1712
1834
|
};
|
|
1713
|
-
var
|
|
1835
|
+
var validatePatchesCmd = defineCommand({
|
|
1714
1836
|
meta: {
|
|
1715
|
-
name: "
|
|
1716
|
-
description: "Validate each finding's patch against the real PR-head tree
|
|
1837
|
+
name: "validate-patches",
|
|
1838
|
+
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
1839
|
},
|
|
1718
1840
|
args: {
|
|
1719
1841
|
findings: {
|
|
@@ -1729,11 +1851,11 @@ var lowerSuggestionsCmd = defineCommand({
|
|
|
1729
1851
|
run: async ({ args }) => {
|
|
1730
1852
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1731
1853
|
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
1732
|
-
const
|
|
1854
|
+
const validated = {
|
|
1733
1855
|
...findings,
|
|
1734
|
-
findings: findings.findings.map((f) =>
|
|
1856
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
1735
1857
|
};
|
|
1736
|
-
process.stdout.write(`${JSON.stringify(
|
|
1858
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
1737
1859
|
`);
|
|
1738
1860
|
}
|
|
1739
1861
|
});
|
|
@@ -1783,6 +1905,97 @@ var printSchemaCmd = defineCommand({
|
|
|
1783
1905
|
`);
|
|
1784
1906
|
}
|
|
1785
1907
|
});
|
|
1908
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
1909
|
+
var drainStdin = () => {
|
|
1910
|
+
if (process.stdin.isTTY) return;
|
|
1911
|
+
try {
|
|
1912
|
+
readFileSync(0);
|
|
1913
|
+
} catch {
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
var requireMaxNudges = (raw) => {
|
|
1917
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
1918
|
+
if (!/^\d+$/.test(raw)) {
|
|
1919
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
1920
|
+
}
|
|
1921
|
+
const n = Number.parseInt(raw, 10);
|
|
1922
|
+
if (n < 1) {
|
|
1923
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
1924
|
+
}
|
|
1925
|
+
return n;
|
|
1926
|
+
};
|
|
1927
|
+
var stopGateCmd = defineCommand({
|
|
1928
|
+
meta: {
|
|
1929
|
+
name: "stop-gate",
|
|
1930
|
+
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."
|
|
1931
|
+
},
|
|
1932
|
+
args: {
|
|
1933
|
+
draft: {
|
|
1934
|
+
type: "string",
|
|
1935
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
1936
|
+
required: true
|
|
1937
|
+
},
|
|
1938
|
+
kind: {
|
|
1939
|
+
type: "string",
|
|
1940
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
1941
|
+
},
|
|
1942
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
1943
|
+
"schema-version": {
|
|
1944
|
+
type: "string",
|
|
1945
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
1946
|
+
},
|
|
1947
|
+
"max-nudges": {
|
|
1948
|
+
type: "string",
|
|
1949
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
1950
|
+
},
|
|
1951
|
+
counter: {
|
|
1952
|
+
type: "string",
|
|
1953
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
1954
|
+
},
|
|
1955
|
+
"print-settings": {
|
|
1956
|
+
type: "boolean",
|
|
1957
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
1958
|
+
}
|
|
1959
|
+
},
|
|
1960
|
+
run: async ({ args }) => {
|
|
1961
|
+
const draftPath = resolve$1(args.draft);
|
|
1962
|
+
if (args["print-settings"]) {
|
|
1963
|
+
const command = defaultHookCommand(draftPath, {
|
|
1964
|
+
kind: args.kind,
|
|
1965
|
+
schema: args.schema,
|
|
1966
|
+
schemaVersion: args["schema-version"],
|
|
1967
|
+
maxNudges: args["max-nudges"],
|
|
1968
|
+
counter: args.counter
|
|
1969
|
+
});
|
|
1970
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
1971
|
+
`);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
drainStdin();
|
|
1975
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
1976
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
1977
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
1978
|
+
const state = draftState(
|
|
1979
|
+
draftPath,
|
|
1980
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
1981
|
+
);
|
|
1982
|
+
const nudges = readNudges(counterPath);
|
|
1983
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
1984
|
+
if (decision.kind === "block") {
|
|
1985
|
+
try {
|
|
1986
|
+
bumpNudges(counterPath, nudges);
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
process.stderr.write(
|
|
1989
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
|
|
1990
|
+
`
|
|
1991
|
+
);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
1995
|
+
`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1786
1999
|
var gatherCmd = defineCommand({
|
|
1787
2000
|
meta: {
|
|
1788
2001
|
name: "gather",
|
|
@@ -1867,7 +2080,7 @@ var postCmd = defineCommand({
|
|
|
1867
2080
|
},
|
|
1868
2081
|
"inline-template": {
|
|
1869
2082
|
type: "string",
|
|
1870
|
-
description: "Path to inline comment Eta template (default:
|
|
2083
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1871
2084
|
},
|
|
1872
2085
|
route: {
|
|
1873
2086
|
type: "string",
|
|
@@ -1899,15 +2112,17 @@ var postCmd = defineCommand({
|
|
|
1899
2112
|
}
|
|
1900
2113
|
},
|
|
1901
2114
|
run: async ({ args }) => {
|
|
2115
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1902
2116
|
await post({
|
|
1903
2117
|
repo: args.repo,
|
|
1904
2118
|
headSha: args["head-sha"],
|
|
1905
2119
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1906
2120
|
findingsPath: args.findings,
|
|
1907
2121
|
envelopePath: args.usage,
|
|
1908
|
-
pricesPath:
|
|
2122
|
+
pricesPath: priceResolution.path,
|
|
2123
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1909
2124
|
templatePath: resolveTemplatePath(args.template),
|
|
1910
|
-
inlineTemplatePath:
|
|
2125
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1911
2126
|
route: args.route,
|
|
1912
2127
|
headBranch: args["head-branch"],
|
|
1913
2128
|
effort: args.effort,
|
|
@@ -1921,7 +2136,7 @@ var main = defineCommand({
|
|
|
1921
2136
|
meta: {
|
|
1922
2137
|
name: "code-review",
|
|
1923
2138
|
version: packageVersion,
|
|
1924
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract,
|
|
2139
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, validate, and stop-gate findings JSON"
|
|
1925
2140
|
},
|
|
1926
2141
|
subCommands: {
|
|
1927
2142
|
gather: gatherCmd,
|
|
@@ -1932,8 +2147,9 @@ var main = defineCommand({
|
|
|
1932
2147
|
validate: validateCmd,
|
|
1933
2148
|
adapt: adaptCmd,
|
|
1934
2149
|
extract: extractCmd,
|
|
1935
|
-
"
|
|
1936
|
-
"print-schema": printSchemaCmd
|
|
2150
|
+
"validate-patches": validatePatchesCmd,
|
|
2151
|
+
"print-schema": printSchemaCmd,
|
|
2152
|
+
"stop-gate": stopGateCmd
|
|
1937
2153
|
}
|
|
1938
2154
|
});
|
|
1939
2155
|
if (!process.env["VITEST"]) {
|