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

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 CHANGED
@@ -42,6 +42,7 @@ npx @jphutchins/code-review <subcommand>
42
42
  | `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
43
43
  | `adapt` | Map a native agent-CLI result envelope onto the abstract SPEC §6.1 envelope |
44
44
  | `extract` | Recover findings/triage JSON from a native envelope via the deterministic extraction ladder |
45
+ | `lower-suggestions` | Validate each finding's `patch` against the real PR-head file and lower it to an exact `suggestion` + line range, or drop it |
45
46
  | `cost` | Recompute USD cost from the envelope's per-model token counts + a price map |
46
47
  | `validate` | Validate findings JSON against the published schema |
47
48
  | `print-schema` | Print a bundled schema (findings, triage, prices) |
@@ -75,6 +76,23 @@ endpoint is a per-repo **Actions variable** (`API_BASE_URL`, required, no defaul
75
76
  another provider requires adding that provider's API host to the workflow's egress allowlist in the
76
77
  same reviewed PR.
77
78
 
79
+ ## Artifacts
80
+
81
+ The review job uploads two artifacts, each visible from the workflow run the sticky's disclosure
82
+ links to:
83
+
84
+ - **`code-review-findings`** — the findings JSON + result envelope the comment job renders. The
85
+ sticky comment embeds this same JSON directly, base64-encoded, in an
86
+ `<!-- code-review:findings-json;base64 <base64> -->` HTML comment — a reviewing agent (or any
87
+ downstream tool) SHOULD base64-decode and parse that marker rather than parse the comment's prose.
88
+ Embedding in the comment (rather than only linking the artifact) keeps the pointer from expiring
89
+ with artifact retention; when the encoded findings are too large to embed, the sticky falls back to
90
+ a `<!-- code-review:findings-json <url> -->` link marker instead
91
+ ([SPEC §5.1 item 7](SPEC.md#51-sticky-summary-comment)).
92
+ - **`code-review-transcript`** — the full Claude Code session transcripts for the triage and review
93
+ phases. This is advisory/auditability only: it is never read by the comment job and never affects
94
+ what gets posted.
95
+
78
96
  ## What's here
79
97
 
80
98
  - **[SPEC.md](SPEC.md)** — the normative, provider-agnostic specification.
package/dist/index.js CHANGED
@@ -73,6 +73,7 @@ var computeSeverityCounts = (findings) => findings.reduce(
73
73
  (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
74
74
  emptySeverityCounts()
75
75
  );
76
+ var EMBED_LIMIT = 4e4;
76
77
  var render = (input) => {
77
78
  const eta = new Eta({ autoTrim: false });
78
79
  const usageAvailable = input.envelope !== null;
@@ -80,6 +81,8 @@ var render = (input) => {
80
81
  const route = input.route ?? input.envelope?.route ?? null;
81
82
  const effort = input.effort ?? input.envelope?.effort ?? null;
82
83
  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;
83
86
  return eta.renderString(input.template, {
84
87
  findings: input.findings,
85
88
  envelope: input.envelope,
@@ -93,8 +96,12 @@ var render = (input) => {
93
96
  severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
94
97
  strays: (input.strays ?? []).map(sanitizeFinding),
95
98
  inlineDisposition: input.inlineDisposition ?? null,
99
+ runUrl: input.runUrl ?? null,
100
+ jsonUrl: input.jsonUrl ?? null,
101
+ embeddedFindings,
102
+ reviewUrl: input.reviewUrl ?? null,
96
103
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
97
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
104
+ formatCost: (n) => Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
98
105
  formatDuration: (ms) => {
99
106
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
100
107
  const s = Math.round(ms / 1e3);
@@ -183,8 +190,26 @@ var partitionFindings = (findings, index) => {
183
190
 
184
191
  // src/inline.ts
185
192
  var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
186
- var buildCommentBody = (f) => {
187
- const parts = [f.body];
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
+ 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];
188
213
  if (f.suggestion !== null && f.suggestion !== void 0) {
189
214
  const safe = escapeBackticks2(f.suggestion);
190
215
  parts.push(`\`\`\`suggestion
@@ -193,22 +218,27 @@ ${safe}
193
218
  }
194
219
  return parts.join("\n\n");
195
220
  };
196
- var renderCommentBody = (f, eta, template) => {
221
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl) => {
197
222
  return eta.renderString(template, {
198
223
  ...f,
199
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
224
+ suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null,
225
+ severityEmoji,
226
+ modelsText,
227
+ jsonUrl: jsonUrl ?? null
200
228
  });
201
229
  };
202
- var buildInlineComments = (findings, diff, inlineTemplate) => {
230
+ var buildInlineComments = (findings, diff, context = {}) => {
231
+ const { inlineTemplate, models = [], jsonUrl } = context;
203
232
  const index = indexDiff(diff);
204
233
  const { inDiff, strays } = partitionFindings(findings, index);
205
234
  const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
235
+ const modelsText = formatModels(models);
206
236
  const comments = inDiff.map((f) => {
207
237
  const comment = {
208
238
  path: f.path,
209
239
  line: f.end_line,
210
240
  side: defaultSide(f.side),
211
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
241
+ body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl) : buildCommentBody(f, jsonUrl)
212
242
  };
213
243
  if (f.start_line < f.end_line) {
214
244
  return {
@@ -269,7 +299,9 @@ var FindingShape = t.intersection([
269
299
  suggestion: t.union([t.string, t.null]),
270
300
  confidence: Confidence,
271
301
  code: t.string,
272
- code_url: t.string
302
+ code_url: t.string,
303
+ reasoning: t.string,
304
+ patch: t.string
273
305
  })
274
306
  ]);
275
307
  var EndGeStart = t.refinement(
@@ -345,7 +377,7 @@ var TestSummaryCodec = t.intersection([
345
377
  failures: t.array(TestFailureCodec)
346
378
  })
347
379
  ]);
348
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
380
+ var DEFAULT_SCHEMA_VERSION = "0.3.0";
349
381
 
350
382
  // src/validate.ts
351
383
  var addFormats = _addFormats;
@@ -381,15 +413,45 @@ var unsafeUnwrap = (decoded) => {
381
413
  if (decoded._tag === "Right") return decoded.right;
382
414
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
383
415
  };
416
+
417
+ // src/format.ts
418
+ var FENCE_RE = /^\s*```/;
419
+ var scanLine = (state, line) => {
420
+ if (FENCE_RE.test(line)) {
421
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
422
+ }
423
+ if (state.inFence) {
424
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
425
+ }
426
+ const trimmed = line.replace(/[ \t]+$/, "");
427
+ if (trimmed !== "") {
428
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
429
+ }
430
+ const blankRun = state.blankRun + 1;
431
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
432
+ };
433
+ var formatMarkdown = (md) => {
434
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
435
+ return `${lines.join("\n").replace(/\n+$/, "")}
436
+ `;
437
+ };
384
438
  var identity = (decoded) => decoded;
385
439
  var findingsTable = [
386
440
  {
387
- minor: "0.2",
441
+ minor: "0.3",
388
442
  defaultVersion: DEFAULT_SCHEMA_VERSION,
389
443
  schemaFile: "findings.schema.json",
390
444
  codec: FindingsCodec,
391
445
  normalize: identity,
392
446
  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
393
455
  }
394
456
  ];
395
457
  var triageTable = [
@@ -595,9 +657,19 @@ var loadTestReport = (path) => {
595
657
  }
596
658
  return decoded.right;
597
659
  };
598
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
660
+ var parseHtmlUrl = (raw) => {
661
+ try {
662
+ const parsed = JSON.parse(raw);
663
+ return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
664
+ } catch {
665
+ return void 0;
666
+ }
667
+ };
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.`;
599
671
  const body = JSON.stringify({
600
- body: `\u{1F916} Automated code review for \`${headSha.slice(0, 7)}\` \u2014 verdict, walkthrough, and cost are in the summary comment.`,
672
+ body: pointer,
601
673
  commit_id: headSha,
602
674
  event: "COMMENT",
603
675
  comments: comments.map((c) => ({
@@ -605,10 +677,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
605
677
  line: c.line,
606
678
  side: c.side,
607
679
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
608
- body: c.body
680
+ body: formatMarkdown(c.body)
609
681
  }))
610
682
  });
611
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
683
+ const stdout = await ghApi(
684
+ [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
685
+ body
686
+ );
687
+ return parseHtmlUrl(stdout);
612
688
  };
613
689
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
614
690
  const stdout = await ghApi(
@@ -628,30 +704,42 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
628
704
  const parsed = JSON.parse(last);
629
705
  return { id: parsed.id, body: parsed.body };
630
706
  };
707
+ var parseCommentRef = (raw) => {
708
+ try {
709
+ const parsed = JSON.parse(raw);
710
+ return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
711
+ } catch {
712
+ return null;
713
+ }
714
+ };
631
715
  var patchComment = async (repo, commentId, body, ghApi) => {
632
- await ghApi(
716
+ const stdout = await ghApi(
633
717
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
634
718
  JSON.stringify({ body })
635
719
  );
720
+ const htmlUrl = parseHtmlUrl(stdout);
721
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
636
722
  };
637
723
  var postComment = async (repo, prNumber, body, ghApi) => {
638
- await ghApi(
724
+ const stdout = await ghApi(
639
725
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
640
726
  JSON.stringify({ body })
641
727
  );
728
+ return parseCommentRef(stdout);
642
729
  };
643
730
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
644
731
  if (existing !== null) {
645
- await patchComment(repo, existing.id, body, ghApi);
732
+ const patched = await patchComment(repo, existing.id, body, ghApi);
646
733
  process.stderr.write(
647
734
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
648
735
  `
649
736
  );
650
- } else {
651
- await postComment(repo, prNumber, body, ghApi);
652
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
653
- `);
737
+ return { id: existing.id, url: patched?.html_url };
654
738
  }
739
+ const posted = await postComment(repo, prNumber, body, ghApi);
740
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
741
+ `);
742
+ return posted ? { id: posted.id, url: posted.html_url } : null;
655
743
  };
656
744
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
657
745
  var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
@@ -720,15 +808,19 @@ var post = async (input, ghApi = runGhApi) => {
720
808
  }
721
809
  const template = readFileSync(input.templatePath, "utf-8");
722
810
  const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
723
- const renderNotice = (message) => render({
724
- findings: noticeFindings(message),
725
- envelope: null,
726
- prices: decodedPrices.right,
727
- template,
728
- route: input.route,
729
- reviewedSha: input.headSha,
730
- effort: input.effort
731
- });
811
+ const renderNotice = (message) => formatMarkdown(
812
+ render({
813
+ findings: noticeFindings(message),
814
+ envelope: null,
815
+ prices: decodedPrices.right,
816
+ template,
817
+ route: input.route,
818
+ reviewedSha: input.headSha,
819
+ effort: input.effort,
820
+ runUrl: input.runUrl,
821
+ jsonUrl: input.jsonUrl
822
+ })
823
+ );
732
824
  if (isEmptyDiff(diff)) {
733
825
  await upsertSticky(
734
826
  input.repo,
@@ -754,28 +846,32 @@ var post = async (input, ghApi = runGhApi) => {
754
846
  const envelope = loadEnvelope(input.envelopePath);
755
847
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
756
848
  if (envelope === null) {
757
- const body2 = render({
758
- findings,
759
- envelope: null,
760
- prices: decodedPrices.right,
761
- template,
762
- route: input.route,
763
- reviewedSha: input.headSha,
764
- effort: input.effort,
765
- testReport,
766
- inlineDisposition: { kind: "no-envelope" }
767
- });
768
- await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
849
+ const body = formatMarkdown(
850
+ render({
851
+ findings,
852
+ envelope: null,
853
+ prices: decodedPrices.right,
854
+ template,
855
+ route: input.route,
856
+ reviewedSha: input.headSha,
857
+ effort: input.effort,
858
+ testReport,
859
+ inlineDisposition: { kind: "no-envelope" },
860
+ runUrl: input.runUrl,
861
+ jsonUrl: input.jsonUrl
862
+ })
863
+ );
864
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
769
865
  process.stderr.write(
770
866
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
771
867
  );
772
868
  process.exit(0);
773
869
  }
774
- const { comments: rawComments, strays } = buildInlineComments(
775
- findings.findings,
776
- diff,
777
- inlineTemplate
778
- );
870
+ const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
871
+ inlineTemplate,
872
+ models: envelope.models.map((m) => m.model),
873
+ jsonUrl: input.jsonUrl
874
+ });
779
875
  const { comments, longFiles } = checkLongSuggestions(rawComments);
780
876
  for (const wf of longFiles) {
781
877
  process.stderr.write(
@@ -786,7 +882,7 @@ var post = async (input, ghApi = runGhApi) => {
786
882
  const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
787
883
  const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
788
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;
789
- const body = render({
885
+ const commonRenderInput = {
790
886
  findings,
791
887
  envelope,
792
888
  prices: decodedPrices.right,
@@ -797,14 +893,18 @@ var post = async (input, ghApi = runGhApi) => {
797
893
  testReport,
798
894
  severityCounts: computeSeverityCounts(findings.findings),
799
895
  strays,
800
- inlineDisposition
801
- }) + (longFiles.length > 0 ? `
896
+ inlineDisposition,
897
+ runUrl: input.runUrl,
898
+ jsonUrl: input.jsonUrl
899
+ };
900
+ const longFilesNote = longFiles.length > 0 ? `
802
901
 
803
902
  ---
804
903
 
805
904
  > **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
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
905
+ ` : "";
906
+ const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
907
+ const stickyRef = await upsertSticky(input.repo, prNumber, existingSticky, renderBody(), ghApi);
808
908
  if (comments.length === 0) return;
809
909
  if (alreadyReviewedThisSha) {
810
910
  process.stderr.write(
@@ -817,11 +917,30 @@ var post = async (input, ghApi = runGhApi) => {
817
917
  if (stalePriorReviewIds.length > 0) {
818
918
  await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
819
919
  }
820
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
920
+ const reviewUrl = await postInlineReview(
921
+ input.repo,
922
+ prNumber,
923
+ input.headSha,
924
+ comments,
925
+ stickyRef?.url,
926
+ ghApi
927
+ );
821
928
  process.stderr.write(
822
929
  `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
823
930
  `
824
931
  );
932
+ if (stickyRef !== null && reviewUrl !== void 0) {
933
+ try {
934
+ await patchComment(input.repo, stickyRef.id, renderBody(reviewUrl), ghApi);
935
+ process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
936
+ `);
937
+ } catch (err) {
938
+ process.stderr.write(
939
+ `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
940
+ `
941
+ );
942
+ }
943
+ }
825
944
  };
826
945
  var renderOutputs = (result) => {
827
946
  switch (result.kind) {
@@ -1032,7 +1151,7 @@ var candidateFromJsonText = (kind, text) => {
1032
1151
  };
1033
1152
  var FENCE_OPEN = /^\s*(`{3,})/;
1034
1153
  var FENCE_MARKER_ONLY = /^`+$/;
1035
- var scanLine = (state, line) => {
1154
+ var scanLine2 = (state, line) => {
1036
1155
  if (state.openLength === null) {
1037
1156
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1038
1157
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1041,7 +1160,7 @@ var scanLine = (state, line) => {
1041
1160
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1042
1161
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1043
1162
  };
1044
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
1163
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1045
1164
  var ladderFailureDiagnostics = (input) => {
1046
1165
  const native = parseNativeForExtraction(input.native);
1047
1166
  const preview = (s) => {
@@ -1176,6 +1295,83 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1176
1295
  }
1177
1296
  };
1178
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);
1315
+ };
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");
1321
+ };
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)}`);
1343
+ }
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
+ );
1357
+ }
1358
+ if (!isContiguousChange(trimmedMiddle(body))) {
1359
+ return drop("change is not a single contiguous block");
1360
+ }
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
+ };
1374
+
1179
1375
  // src/index.ts
1180
1376
  var readJSON = (path) => {
1181
1377
  try {
@@ -1305,7 +1501,7 @@ var inlineCmd = defineCommand({
1305
1501
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1306
1502
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1307
1503
  const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1308
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
1504
+ const { comments, strays } = buildInlineComments(findings.findings, diff, { inlineTemplate });
1309
1505
  process.stdout.write(
1310
1506
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1311
1507
  );
@@ -1475,6 +1671,72 @@ ${ladderFailureDiagnostics(input)}
1475
1671
  fail(describeLadderFailure(outcome));
1476
1672
  }
1477
1673
  });
1674
+ var withoutPatch = (finding) => {
1675
+ const copy = { ...finding };
1676
+ delete copy.patch;
1677
+ return copy;
1678
+ };
1679
+ var readFileLines = (path) => {
1680
+ try {
1681
+ const rawLines = readFileSync(path, "utf-8").split("\n");
1682
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
1683
+ } catch {
1684
+ return null;
1685
+ }
1686
+ };
1687
+ var lowerFinding = (finding, repoRoot) => {
1688
+ if (finding.patch === void 0) return finding;
1689
+ const base = withoutPatch(finding);
1690
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
1691
+ if (lines === null) {
1692
+ process.stderr.write(
1693
+ `lower-suggestions: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
1694
+ `
1695
+ );
1696
+ return base;
1697
+ }
1698
+ const result = lowerPatch(finding.patch, lines);
1699
+ if (result.kind === "drop") {
1700
+ process.stderr.write(
1701
+ `lower-suggestions: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
1702
+ `
1703
+ );
1704
+ return base;
1705
+ }
1706
+ return {
1707
+ ...base,
1708
+ suggestion: result.suggestion,
1709
+ start_line: result.startLine,
1710
+ end_line: result.endLine
1711
+ };
1712
+ };
1713
+ var lowerSuggestionsCmd = defineCommand({
1714
+ 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)"
1717
+ },
1718
+ args: {
1719
+ findings: {
1720
+ type: "positional",
1721
+ description: "Path to findings JSON",
1722
+ required: true
1723
+ },
1724
+ "repo-root": {
1725
+ type: "string",
1726
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
1727
+ }
1728
+ },
1729
+ run: async ({ args }) => {
1730
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1731
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
1732
+ const lowered = {
1733
+ ...findings,
1734
+ findings: findings.findings.map((f) => lowerFinding(f, repoRoot))
1735
+ };
1736
+ process.stdout.write(`${JSON.stringify(lowered, null, 2)}
1737
+ `);
1738
+ }
1739
+ });
1478
1740
  var requireAdapterName = (name) => {
1479
1741
  if (isAdapterName(name)) return name;
1480
1742
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1626,6 +1888,14 @@ var postCmd = defineCommand({
1626
1888
  "test-report": {
1627
1889
  type: "string",
1628
1890
  description: TEST_REPORT_DESCRIPTION
1891
+ },
1892
+ "run-url": {
1893
+ type: "string",
1894
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
1895
+ },
1896
+ "json-url": {
1897
+ type: "string",
1898
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1629
1899
  }
1630
1900
  },
1631
1901
  run: async ({ args }) => {
@@ -1641,7 +1911,9 @@ var postCmd = defineCommand({
1641
1911
  route: args.route,
1642
1912
  headBranch: args["head-branch"],
1643
1913
  effort: args.effort,
1644
- testReportPath: args["test-report"]
1914
+ testReportPath: args["test-report"],
1915
+ runUrl: args["run-url"],
1916
+ jsonUrl: args["json-url"]
1645
1917
  });
1646
1918
  }
1647
1919
  });
@@ -1649,7 +1921,7 @@ var main = defineCommand({
1649
1921
  meta: {
1650
1922
  name: "code-review",
1651
1923
  version: packageVersion,
1652
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
1924
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, lower-suggestions, cost, and validate findings JSON"
1653
1925
  },
1654
1926
  subCommands: {
1655
1927
  gather: gatherCmd,
@@ -1660,6 +1932,7 @@ var main = defineCommand({
1660
1932
  validate: validateCmd,
1661
1933
  adapt: adaptCmd,
1662
1934
  extract: extractCmd,
1935
+ "lower-suggestions": lowerSuggestionsCmd,
1663
1936
  "print-schema": printSchemaCmd
1664
1937
  }
1665
1938
  });