@jphutchins/code-review 0.1.0-alpha.5 → 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 +18 -0
- package/dist/index.js +612 -123
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/VERSIONING.md +3 -1
- package/schema/findings.schema.json +25 -8
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +46 -21
- package/templates/inline.eta +28 -5
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,
|
|
@@ -77,6 +200,7 @@ var render = (input) => {
|
|
|
77
200
|
const eta = new Eta({ autoTrim: false });
|
|
78
201
|
const usageAvailable = input.envelope !== null;
|
|
79
202
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
203
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
80
204
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
81
205
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
82
206
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
@@ -85,6 +209,7 @@ var render = (input) => {
|
|
|
85
209
|
envelope: input.envelope,
|
|
86
210
|
usageAvailable,
|
|
87
211
|
costReport,
|
|
212
|
+
pricesProvided,
|
|
88
213
|
route,
|
|
89
214
|
effort,
|
|
90
215
|
modelNames,
|
|
@@ -93,8 +218,14 @@ var render = (input) => {
|
|
|
93
218
|
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
94
219
|
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
95
220
|
inlineDisposition: input.inlineDisposition ?? null,
|
|
221
|
+
runUrl: input.runUrl ?? null,
|
|
222
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
223
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
224
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
96
225
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
97
|
-
|
|
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",
|
|
98
229
|
formatDuration: (ms) => {
|
|
99
230
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
100
231
|
const s = Math.round(ms / 1e3);
|
|
@@ -112,20 +243,7 @@ var render = (input) => {
|
|
|
112
243
|
return `\u2753 ${v}`;
|
|
113
244
|
}
|
|
114
245
|
},
|
|
115
|
-
severityEmoji
|
|
116
|
-
switch (s) {
|
|
117
|
-
case "critical":
|
|
118
|
-
return "\u{1F534}";
|
|
119
|
-
case "major":
|
|
120
|
-
return "\u{1F7E0}";
|
|
121
|
-
case "minor":
|
|
122
|
-
return "\u{1F535}";
|
|
123
|
-
case "nit":
|
|
124
|
-
return "\u26AA";
|
|
125
|
-
default:
|
|
126
|
-
return "\u2753";
|
|
127
|
-
}
|
|
128
|
-
}
|
|
246
|
+
severityEmoji
|
|
129
247
|
});
|
|
130
248
|
};
|
|
131
249
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -182,33 +300,32 @@ var partitionFindings = (findings, index) => {
|
|
|
182
300
|
};
|
|
183
301
|
|
|
184
302
|
// src/inline.ts
|
|
185
|
-
var
|
|
186
|
-
var
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
parts.push(`\`\`\`suggestion
|
|
191
|
-
${safe}
|
|
192
|
-
\`\`\``);
|
|
193
|
-
}
|
|
194
|
-
return parts.join("\n\n");
|
|
195
|
-
};
|
|
196
|
-
var renderCommentBody = (f, eta, template) => {
|
|
197
|
-
return eta.renderString(template, {
|
|
303
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
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, {
|
|
198
308
|
...f,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
309
|
+
patchProjection: projectPatch(f.patch),
|
|
310
|
+
severityEmoji,
|
|
311
|
+
modelsText,
|
|
312
|
+
jsonUrl: jsonUrl ?? null,
|
|
313
|
+
findingsPointer: pointer
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
317
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
203
318
|
const index = indexDiff(diff);
|
|
204
319
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
205
|
-
const eta =
|
|
320
|
+
const eta = new Eta({ autoTrim: false });
|
|
321
|
+
const modelsText = formatModels(models);
|
|
322
|
+
const pointer = context.findingsPointer ?? (fullFindings ? findingsPointer(fullFindings, jsonUrl) : "");
|
|
206
323
|
const comments = inDiff.map((f) => {
|
|
207
324
|
const comment = {
|
|
208
325
|
path: f.path,
|
|
209
326
|
line: f.end_line,
|
|
210
327
|
side: defaultSide(f.side),
|
|
211
|
-
body:
|
|
328
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
212
329
|
};
|
|
213
330
|
if (f.start_line < f.end_line) {
|
|
214
331
|
return {
|
|
@@ -262,14 +379,16 @@ var FindingShape = t.intersection([
|
|
|
262
379
|
end_line: LineNumber,
|
|
263
380
|
severity: SeverityCodec,
|
|
264
381
|
title: t.string,
|
|
265
|
-
|
|
382
|
+
description: t.string,
|
|
383
|
+
reasoning: t.string,
|
|
384
|
+
confidence: Confidence
|
|
266
385
|
}),
|
|
267
386
|
t.partial({
|
|
268
387
|
side: SideCodec,
|
|
269
|
-
suggestion: t.union([t.string, t.null]),
|
|
270
|
-
confidence: Confidence,
|
|
271
388
|
code: t.string,
|
|
272
|
-
code_url: t.string
|
|
389
|
+
code_url: t.string,
|
|
390
|
+
recommendation: t.string,
|
|
391
|
+
patch: t.union([t.string, t.null])
|
|
273
392
|
})
|
|
274
393
|
]);
|
|
275
394
|
var EndGeStart = t.refinement(
|
|
@@ -345,7 +464,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
345
464
|
failures: t.array(TestFailureCodec)
|
|
346
465
|
})
|
|
347
466
|
]);
|
|
348
|
-
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
|
+
});
|
|
349
474
|
|
|
350
475
|
// src/validate.ts
|
|
351
476
|
var addFormats = _addFormats;
|
|
@@ -381,10 +506,32 @@ var unsafeUnwrap = (decoded) => {
|
|
|
381
506
|
if (decoded._tag === "Right") return decoded.right;
|
|
382
507
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
383
508
|
};
|
|
509
|
+
|
|
510
|
+
// src/format.ts
|
|
511
|
+
var FENCE_RE = /^\s*```/;
|
|
512
|
+
var scanLine = (state, line) => {
|
|
513
|
+
if (FENCE_RE.test(line)) {
|
|
514
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
515
|
+
}
|
|
516
|
+
if (state.inFence) {
|
|
517
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
518
|
+
}
|
|
519
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
520
|
+
if (trimmed !== "") {
|
|
521
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
522
|
+
}
|
|
523
|
+
const blankRun = state.blankRun + 1;
|
|
524
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
525
|
+
};
|
|
526
|
+
var formatMarkdown = (md) => {
|
|
527
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
528
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
529
|
+
`;
|
|
530
|
+
};
|
|
384
531
|
var identity = (decoded) => decoded;
|
|
385
532
|
var findingsTable = [
|
|
386
533
|
{
|
|
387
|
-
minor: "0.
|
|
534
|
+
minor: "0.4",
|
|
388
535
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
389
536
|
schemaFile: "findings.schema.json",
|
|
390
537
|
codec: FindingsCodec,
|
|
@@ -528,12 +675,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
528
675
|
});
|
|
529
676
|
return { comments: adjusted, longFiles };
|
|
530
677
|
};
|
|
531
|
-
var noticeFindings = (message) => ({
|
|
532
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
533
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
534
|
-
verdict: "comment",
|
|
535
|
-
findings: []
|
|
536
|
-
});
|
|
537
678
|
var loadFindings = (path) => {
|
|
538
679
|
let raw;
|
|
539
680
|
try {
|
|
@@ -595,9 +736,22 @@ var loadTestReport = (path) => {
|
|
|
595
736
|
}
|
|
596
737
|
return decoded.right;
|
|
597
738
|
};
|
|
598
|
-
var
|
|
739
|
+
var parseHtmlUrl = (raw) => {
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(raw);
|
|
742
|
+
return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
|
|
743
|
+
} catch {
|
|
744
|
+
return void 0;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
|
|
748
|
+
const sha7 = headSha.slice(0, 7);
|
|
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;
|
|
599
753
|
const body = JSON.stringify({
|
|
600
|
-
body:
|
|
754
|
+
body: pointer,
|
|
601
755
|
commit_id: headSha,
|
|
602
756
|
event: "COMMENT",
|
|
603
757
|
comments: comments.map((c) => ({
|
|
@@ -605,10 +759,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
|
|
|
605
759
|
line: c.line,
|
|
606
760
|
side: c.side,
|
|
607
761
|
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
608
|
-
body: c.body
|
|
762
|
+
body: formatMarkdown(c.body)
|
|
609
763
|
}))
|
|
610
764
|
});
|
|
611
|
-
await ghApi(
|
|
765
|
+
const stdout = await ghApi(
|
|
766
|
+
[`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
|
|
767
|
+
body
|
|
768
|
+
);
|
|
769
|
+
return parseHtmlUrl(stdout);
|
|
612
770
|
};
|
|
613
771
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
614
772
|
const stdout = await ghApi(
|
|
@@ -628,30 +786,42 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
628
786
|
const parsed = JSON.parse(last);
|
|
629
787
|
return { id: parsed.id, body: parsed.body };
|
|
630
788
|
};
|
|
789
|
+
var parseCommentRef = (raw) => {
|
|
790
|
+
try {
|
|
791
|
+
const parsed = JSON.parse(raw);
|
|
792
|
+
return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
|
|
793
|
+
} catch {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
631
797
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
632
|
-
await ghApi(
|
|
798
|
+
const stdout = await ghApi(
|
|
633
799
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
634
800
|
JSON.stringify({ body })
|
|
635
801
|
);
|
|
802
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
803
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
636
804
|
};
|
|
637
805
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
638
|
-
await ghApi(
|
|
806
|
+
const stdout = await ghApi(
|
|
639
807
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
640
808
|
JSON.stringify({ body })
|
|
641
809
|
);
|
|
810
|
+
return parseCommentRef(stdout);
|
|
642
811
|
};
|
|
643
812
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
644
813
|
if (existing !== null) {
|
|
645
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
814
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
646
815
|
process.stderr.write(
|
|
647
816
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
648
817
|
`
|
|
649
818
|
);
|
|
650
|
-
|
|
651
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
652
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
653
|
-
`);
|
|
819
|
+
return { id: existing.id, url: patched?.html_url };
|
|
654
820
|
}
|
|
821
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
822
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
823
|
+
`);
|
|
824
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
655
825
|
};
|
|
656
826
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
657
827
|
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
@@ -719,16 +889,21 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
719
889
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
720
890
|
}
|
|
721
891
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
722
|
-
const inlineTemplate =
|
|
723
|
-
const renderNotice = (message) =>
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
892
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
893
|
+
const renderNotice = (message) => formatMarkdown(
|
|
894
|
+
render({
|
|
895
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
896
|
+
envelope: null,
|
|
897
|
+
prices: decodedPrices.right,
|
|
898
|
+
pricesProvided: input.pricesProvided,
|
|
899
|
+
template,
|
|
900
|
+
route: input.route,
|
|
901
|
+
reviewedSha: input.headSha,
|
|
902
|
+
effort: input.effort,
|
|
903
|
+
runUrl: input.runUrl,
|
|
904
|
+
jsonUrl: input.jsonUrl
|
|
905
|
+
})
|
|
906
|
+
);
|
|
732
907
|
if (isEmptyDiff(diff)) {
|
|
733
908
|
await upsertSticky(
|
|
734
909
|
input.repo,
|
|
@@ -754,28 +929,34 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
754
929
|
const envelope = loadEnvelope(input.envelopePath);
|
|
755
930
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
756
931
|
if (envelope === null) {
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
932
|
+
const body = formatMarkdown(
|
|
933
|
+
render({
|
|
934
|
+
findings,
|
|
935
|
+
envelope: null,
|
|
936
|
+
prices: decodedPrices.right,
|
|
937
|
+
pricesProvided: input.pricesProvided,
|
|
938
|
+
template,
|
|
939
|
+
route: input.route,
|
|
940
|
+
reviewedSha: input.headSha,
|
|
941
|
+
effort: input.effort,
|
|
942
|
+
testReport,
|
|
943
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
944
|
+
runUrl: input.runUrl,
|
|
945
|
+
jsonUrl: input.jsonUrl
|
|
946
|
+
})
|
|
947
|
+
);
|
|
948
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
769
949
|
process.stderr.write(
|
|
770
950
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
771
951
|
);
|
|
772
952
|
process.exit(0);
|
|
773
953
|
}
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
954
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
955
|
+
const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
|
|
956
|
+
inlineTemplate,
|
|
957
|
+
models: envelope.models.map((m) => m.model),
|
|
958
|
+
findingsPointer: findingsMarker
|
|
959
|
+
});
|
|
779
960
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
780
961
|
for (const wf of longFiles) {
|
|
781
962
|
process.stderr.write(
|
|
@@ -785,11 +966,12 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
785
966
|
}
|
|
786
967
|
const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
|
|
787
968
|
const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
|
|
788
|
-
const
|
|
789
|
-
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;
|
|
970
|
+
const commonRenderInput = {
|
|
790
971
|
findings,
|
|
791
972
|
envelope,
|
|
792
973
|
prices: decodedPrices.right,
|
|
974
|
+
pricesProvided: input.pricesProvided,
|
|
793
975
|
template,
|
|
794
976
|
route: input.route,
|
|
795
977
|
reviewedSha: input.headSha,
|
|
@@ -797,14 +979,24 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
797
979
|
testReport,
|
|
798
980
|
severityCounts: computeSeverityCounts(findings.findings),
|
|
799
981
|
strays,
|
|
800
|
-
|
|
801
|
-
|
|
982
|
+
runUrl: input.runUrl,
|
|
983
|
+
jsonUrl: input.jsonUrl,
|
|
984
|
+
findingsPointer: findingsMarker
|
|
985
|
+
};
|
|
986
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
802
987
|
|
|
803
988
|
---
|
|
804
989
|
|
|
805
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.
|
|
806
|
-
` : ""
|
|
807
|
-
|
|
991
|
+
` : "";
|
|
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
|
+
);
|
|
808
1000
|
if (comments.length === 0) return;
|
|
809
1001
|
if (alreadyReviewedThisSha) {
|
|
810
1002
|
process.stderr.write(
|
|
@@ -817,11 +1009,41 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
817
1009
|
if (stalePriorReviewIds.length > 0) {
|
|
818
1010
|
await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
|
|
819
1011
|
}
|
|
820
|
-
await postInlineReview(
|
|
1012
|
+
const reviewUrl = await postInlineReview(
|
|
1013
|
+
input.repo,
|
|
1014
|
+
prNumber,
|
|
1015
|
+
input.headSha,
|
|
1016
|
+
comments,
|
|
1017
|
+
stickyRef?.url,
|
|
1018
|
+
findingsMarker,
|
|
1019
|
+
ghApi
|
|
1020
|
+
);
|
|
821
1021
|
process.stderr.write(
|
|
822
1022
|
`Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
|
|
823
1023
|
`
|
|
824
1024
|
);
|
|
1025
|
+
if (stickyRef !== null) {
|
|
1026
|
+
const confirmedDisposition = {
|
|
1027
|
+
kind: "posted",
|
|
1028
|
+
count: comments.length,
|
|
1029
|
+
sha: input.headSha
|
|
1030
|
+
};
|
|
1031
|
+
try {
|
|
1032
|
+
await patchComment(
|
|
1033
|
+
input.repo,
|
|
1034
|
+
stickyRef.id,
|
|
1035
|
+
renderBody(confirmedDisposition, reviewUrl),
|
|
1036
|
+
ghApi
|
|
1037
|
+
);
|
|
1038
|
+
process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
|
|
1039
|
+
`);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
process.stderr.write(
|
|
1042
|
+
`Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
|
|
1043
|
+
`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
825
1047
|
};
|
|
826
1048
|
var renderOutputs = (result) => {
|
|
827
1049
|
switch (result.kind) {
|
|
@@ -1032,7 +1254,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1032
1254
|
};
|
|
1033
1255
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1034
1256
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1035
|
-
var
|
|
1257
|
+
var scanLine2 = (state, line) => {
|
|
1036
1258
|
if (state.openLength === null) {
|
|
1037
1259
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1038
1260
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1041,7 +1263,7 @@ var scanLine = (state, line) => {
|
|
|
1041
1263
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1042
1264
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1043
1265
|
};
|
|
1044
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
1266
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1045
1267
|
var ladderFailureDiagnostics = (input) => {
|
|
1046
1268
|
const native = parseNativeForExtraction(input.native);
|
|
1047
1269
|
const preview = (s) => {
|
|
@@ -1144,24 +1366,42 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1144
1366
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1145
1367
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1146
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
|
+
};
|
|
1147
1379
|
var adaptClaudeCode = (native, agentFilePath, meta) => {
|
|
1148
|
-
const
|
|
1149
|
-
if (outcome.kind !== "ok") {
|
|
1150
|
-
return left(describeLadderFailure(outcome));
|
|
1151
|
-
}
|
|
1152
|
-
const resolution = resolve("findings", outcome.candidate);
|
|
1153
|
-
return resolution.kind === "ok" ? right({
|
|
1154
|
-
schema_version: resolution.version,
|
|
1155
|
-
findings: resolution.value,
|
|
1380
|
+
const telemetry = {
|
|
1156
1381
|
models: mapModelUsage(native.modelUsage),
|
|
1157
1382
|
turns: native.num_turns,
|
|
1158
1383
|
duration_ms: native.duration_ms,
|
|
1159
1384
|
vendor_cost_usd: native.total_cost_usd ?? null,
|
|
1160
1385
|
...meta.route ? { route: meta.route } : {},
|
|
1161
1386
|
...meta.effort ? { effort: meta.effort } : {}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
)
|
|
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
|
+
}
|
|
1165
1405
|
};
|
|
1166
1406
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1167
1407
|
switch (adapterName) {
|
|
@@ -1175,6 +1415,86 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
|
1175
1415
|
}
|
|
1176
1416
|
}
|
|
1177
1417
|
};
|
|
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
|
+
}
|
|
1428
|
+
};
|
|
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
|
+
};
|
|
1440
|
+
};
|
|
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) };
|
|
1450
|
+
}
|
|
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
|
+
};
|
|
1459
|
+
}
|
|
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)] };
|
|
1471
|
+
}
|
|
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
|
+
});
|
|
1178
1498
|
|
|
1179
1499
|
// src/index.ts
|
|
1180
1500
|
var readJSON = (path) => {
|
|
@@ -1210,12 +1530,13 @@ var unwrapAdapt = (either) => {
|
|
|
1210
1530
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1211
1531
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1212
1532
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1213
|
-
var
|
|
1214
|
-
|
|
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) };
|
|
1215
1536
|
process.stderr.write(
|
|
1216
|
-
"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"
|
|
1217
1538
|
);
|
|
1218
|
-
return bundledPath("schema", "prices.example.json");
|
|
1539
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1219
1540
|
};
|
|
1220
1541
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1221
1542
|
var renderCmd = defineCommand({
|
|
@@ -1263,14 +1584,15 @@ var renderCmd = defineCommand({
|
|
|
1263
1584
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1264
1585
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1265
1586
|
const templatePath = resolveTemplatePath(args.template);
|
|
1266
|
-
const
|
|
1267
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
1587
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1588
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1268
1589
|
const template = readFileSync(templatePath, "utf-8");
|
|
1269
1590
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1270
1591
|
const output = render({
|
|
1271
1592
|
findings,
|
|
1272
1593
|
envelope,
|
|
1273
1594
|
prices,
|
|
1595
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1274
1596
|
template,
|
|
1275
1597
|
reviewedSha: args["reviewed-sha"],
|
|
1276
1598
|
route: args.route,
|
|
@@ -1298,14 +1620,17 @@ var inlineCmd = defineCommand({
|
|
|
1298
1620
|
},
|
|
1299
1621
|
template: {
|
|
1300
1622
|
type: "string",
|
|
1301
|
-
description: "Path to inline comment Eta template (default:
|
|
1623
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1302
1624
|
}
|
|
1303
1625
|
},
|
|
1304
1626
|
run: async ({ args }) => {
|
|
1305
1627
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1306
1628
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1307
|
-
const inlineTemplate =
|
|
1308
|
-
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
|
+
});
|
|
1309
1634
|
process.stdout.write(
|
|
1310
1635
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1311
1636
|
);
|
|
@@ -1335,8 +1660,7 @@ var costCmd = defineCommand({
|
|
|
1335
1660
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1336
1661
|
}
|
|
1337
1662
|
});
|
|
1338
|
-
var
|
|
1339
|
-
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1663
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
1340
1664
|
var validateCmd = defineCommand({
|
|
1341
1665
|
meta: {
|
|
1342
1666
|
name: "validate",
|
|
@@ -1475,6 +1799,66 @@ ${ladderFailureDiagnostics(input)}
|
|
|
1475
1799
|
fail(describeLadderFailure(outcome));
|
|
1476
1800
|
}
|
|
1477
1801
|
});
|
|
1802
|
+
var withoutPatch = (finding) => {
|
|
1803
|
+
const copy = { ...finding };
|
|
1804
|
+
delete copy.patch;
|
|
1805
|
+
return copy;
|
|
1806
|
+
};
|
|
1807
|
+
var readFileLines = (path) => {
|
|
1808
|
+
try {
|
|
1809
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
1810
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
1811
|
+
} catch {
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
var validateFinding = (finding, repoRoot) => {
|
|
1816
|
+
if (finding.patch === void 0 || finding.patch === null) return finding;
|
|
1817
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
1818
|
+
if (lines === null) {
|
|
1819
|
+
process.stderr.write(
|
|
1820
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
1821
|
+
`
|
|
1822
|
+
);
|
|
1823
|
+
return withoutPatch(finding);
|
|
1824
|
+
}
|
|
1825
|
+
const result = validatePatch(finding.patch, lines);
|
|
1826
|
+
if ("kind" in result) {
|
|
1827
|
+
process.stderr.write(
|
|
1828
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
1829
|
+
`
|
|
1830
|
+
);
|
|
1831
|
+
return withoutPatch(finding);
|
|
1832
|
+
}
|
|
1833
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
1834
|
+
};
|
|
1835
|
+
var validatePatchesCmd = defineCommand({
|
|
1836
|
+
meta: {
|
|
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)"
|
|
1839
|
+
},
|
|
1840
|
+
args: {
|
|
1841
|
+
findings: {
|
|
1842
|
+
type: "positional",
|
|
1843
|
+
description: "Path to findings JSON",
|
|
1844
|
+
required: true
|
|
1845
|
+
},
|
|
1846
|
+
"repo-root": {
|
|
1847
|
+
type: "string",
|
|
1848
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
run: async ({ args }) => {
|
|
1852
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1853
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
1854
|
+
const validated = {
|
|
1855
|
+
...findings,
|
|
1856
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
1857
|
+
};
|
|
1858
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
1859
|
+
`);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1478
1862
|
var requireAdapterName = (name) => {
|
|
1479
1863
|
if (isAdapterName(name)) return name;
|
|
1480
1864
|
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
@@ -1521,6 +1905,97 @@ var printSchemaCmd = defineCommand({
|
|
|
1521
1905
|
`);
|
|
1522
1906
|
}
|
|
1523
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
|
+
});
|
|
1524
1999
|
var gatherCmd = defineCommand({
|
|
1525
2000
|
meta: {
|
|
1526
2001
|
name: "gather",
|
|
@@ -1605,7 +2080,7 @@ var postCmd = defineCommand({
|
|
|
1605
2080
|
},
|
|
1606
2081
|
"inline-template": {
|
|
1607
2082
|
type: "string",
|
|
1608
|
-
description: "Path to inline comment Eta template (default:
|
|
2083
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1609
2084
|
},
|
|
1610
2085
|
route: {
|
|
1611
2086
|
type: "string",
|
|
@@ -1626,22 +2101,34 @@ var postCmd = defineCommand({
|
|
|
1626
2101
|
"test-report": {
|
|
1627
2102
|
type: "string",
|
|
1628
2103
|
description: TEST_REPORT_DESCRIPTION
|
|
2104
|
+
},
|
|
2105
|
+
"run-url": {
|
|
2106
|
+
type: "string",
|
|
2107
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
2108
|
+
},
|
|
2109
|
+
"json-url": {
|
|
2110
|
+
type: "string",
|
|
2111
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1629
2112
|
}
|
|
1630
2113
|
},
|
|
1631
2114
|
run: async ({ args }) => {
|
|
2115
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1632
2116
|
await post({
|
|
1633
2117
|
repo: args.repo,
|
|
1634
2118
|
headSha: args["head-sha"],
|
|
1635
2119
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1636
2120
|
findingsPath: args.findings,
|
|
1637
2121
|
envelopePath: args.usage,
|
|
1638
|
-
pricesPath:
|
|
2122
|
+
pricesPath: priceResolution.path,
|
|
2123
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1639
2124
|
templatePath: resolveTemplatePath(args.template),
|
|
1640
|
-
inlineTemplatePath:
|
|
2125
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1641
2126
|
route: args.route,
|
|
1642
2127
|
headBranch: args["head-branch"],
|
|
1643
2128
|
effort: args.effort,
|
|
1644
|
-
testReportPath: args["test-report"]
|
|
2129
|
+
testReportPath: args["test-report"],
|
|
2130
|
+
runUrl: args["run-url"],
|
|
2131
|
+
jsonUrl: args["json-url"]
|
|
1645
2132
|
});
|
|
1646
2133
|
}
|
|
1647
2134
|
});
|
|
@@ -1649,7 +2136,7 @@ var main = defineCommand({
|
|
|
1649
2136
|
meta: {
|
|
1650
2137
|
name: "code-review",
|
|
1651
2138
|
version: packageVersion,
|
|
1652
|
-
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and
|
|
2139
|
+
description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, validate, and stop-gate findings JSON"
|
|
1653
2140
|
},
|
|
1654
2141
|
subCommands: {
|
|
1655
2142
|
gather: gatherCmd,
|
|
@@ -1660,7 +2147,9 @@ var main = defineCommand({
|
|
|
1660
2147
|
validate: validateCmd,
|
|
1661
2148
|
adapt: adaptCmd,
|
|
1662
2149
|
extract: extractCmd,
|
|
1663
|
-
"
|
|
2150
|
+
"validate-patches": validatePatchesCmd,
|
|
2151
|
+
"print-schema": printSchemaCmd,
|
|
2152
|
+
"stop-gate": stopGateCmd
|
|
1664
2153
|
}
|
|
1665
2154
|
});
|
|
1666
2155
|
if (!process.env["VITEST"]) {
|