@jphutchins/code-review 0.1.0-alpha.6 → 0.1.0-alpha.8

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