@jphutchins/code-review 0.1.0-alpha.4 → 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/dist/index.js CHANGED
@@ -63,6 +63,17 @@ var sanitizeFinding = (f) => ({
63
63
  path: escapeCodeBackticks(f.path),
64
64
  suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
65
65
  });
66
+ var emptySeverityCounts = () => ({
67
+ critical: 0,
68
+ major: 0,
69
+ minor: 0,
70
+ nit: 0
71
+ });
72
+ var computeSeverityCounts = (findings) => findings.reduce(
73
+ (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
74
+ emptySeverityCounts()
75
+ );
76
+ var EMBED_LIMIT = 4e4;
66
77
  var render = (input) => {
67
78
  const eta = new Eta({ autoTrim: false });
68
79
  const usageAvailable = input.envelope !== null;
@@ -70,11 +81,10 @@ var render = (input) => {
70
81
  const route = input.route ?? input.envelope?.route ?? null;
71
82
  const effort = input.effort ?? input.envelope?.effort ?? null;
72
83
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
73
- const findings = input.findings.findings.map(sanitizeFinding);
74
- const safeFindings = { ...input.findings, findings };
75
- const uniqueFiles = [...new Set(findings.map((f) => f.path))];
84
+ const findingsB64 = Buffer.from(JSON.stringify(input.findings), "utf-8").toString("base64");
85
+ const embeddedFindings = findingsB64.length <= EMBED_LIMIT ? findingsB64 : null;
76
86
  return eta.renderString(input.template, {
77
- findings: safeFindings,
87
+ findings: input.findings,
78
88
  envelope: input.envelope,
79
89
  usageAvailable,
80
90
  costReport,
@@ -83,14 +93,15 @@ var render = (input) => {
83
93
  modelNames,
84
94
  testReport: input.testReport ?? null,
85
95
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
86
- totalCount: findings.length,
87
- fileCount: uniqueFiles.length,
88
- // REC-CO-1: nits (and only nits) fold into <details>; everything else stays visible.
89
- visibleFindings: findings.filter((f) => f.severity !== "nit"),
90
- nitFindings: findings.filter((f) => f.severity === "nit"),
91
- suggestionCount: findings.filter((f) => f.suggestion).length,
96
+ severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
97
+ strays: (input.strays ?? []).map(sanitizeFinding),
98
+ inlineDisposition: input.inlineDisposition ?? null,
99
+ runUrl: input.runUrl ?? null,
100
+ jsonUrl: input.jsonUrl ?? null,
101
+ embeddedFindings,
102
+ reviewUrl: input.reviewUrl ?? null,
92
103
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
93
- 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",
94
105
  formatDuration: (ms) => {
95
106
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
96
107
  const s = Math.round(ms / 1e3);
@@ -179,8 +190,26 @@ var partitionFindings = (findings, index) => {
179
190
 
180
191
  // src/inline.ts
181
192
  var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
182
- var buildCommentBody = (f) => {
183
- 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];
184
213
  if (f.suggestion !== null && f.suggestion !== void 0) {
185
214
  const safe = escapeBackticks2(f.suggestion);
186
215
  parts.push(`\`\`\`suggestion
@@ -189,22 +218,27 @@ ${safe}
189
218
  }
190
219
  return parts.join("\n\n");
191
220
  };
192
- var renderCommentBody = (f, eta, template) => {
221
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl) => {
193
222
  return eta.renderString(template, {
194
223
  ...f,
195
- 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
196
228
  });
197
229
  };
198
- var buildInlineComments = (findings, diff, inlineTemplate) => {
230
+ var buildInlineComments = (findings, diff, context = {}) => {
231
+ const { inlineTemplate, models = [], jsonUrl } = context;
199
232
  const index = indexDiff(diff);
200
233
  const { inDiff, strays } = partitionFindings(findings, index);
201
234
  const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
235
+ const modelsText = formatModels(models);
202
236
  const comments = inDiff.map((f) => {
203
237
  const comment = {
204
238
  path: f.path,
205
239
  line: f.end_line,
206
240
  side: defaultSide(f.side),
207
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
241
+ body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl) : buildCommentBody(f, jsonUrl)
208
242
  };
209
243
  if (f.start_line < f.end_line) {
210
244
  return {
@@ -265,7 +299,9 @@ var FindingShape = t.intersection([
265
299
  suggestion: t.union([t.string, t.null]),
266
300
  confidence: Confidence,
267
301
  code: t.string,
268
- code_url: t.string
302
+ code_url: t.string,
303
+ reasoning: t.string,
304
+ patch: t.string
269
305
  })
270
306
  ]);
271
307
  var EndGeStart = t.refinement(
@@ -341,7 +377,7 @@ var TestSummaryCodec = t.intersection([
341
377
  failures: t.array(TestFailureCodec)
342
378
  })
343
379
  ]);
344
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
380
+ var DEFAULT_SCHEMA_VERSION = "0.3.0";
345
381
 
346
382
  // src/validate.ts
347
383
  var addFormats = _addFormats;
@@ -377,15 +413,45 @@ var unsafeUnwrap = (decoded) => {
377
413
  if (decoded._tag === "Right") return decoded.right;
378
414
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
379
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
+ };
380
438
  var identity = (decoded) => decoded;
381
439
  var findingsTable = [
382
440
  {
383
- minor: "0.2",
441
+ minor: "0.3",
384
442
  defaultVersion: DEFAULT_SCHEMA_VERSION,
385
443
  schemaFile: "findings.schema.json",
386
444
  codec: FindingsCodec,
387
445
  normalize: identity,
388
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
389
455
  }
390
456
  ];
391
457
  var triageTable = [
@@ -505,7 +571,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
505
571
  // src/post.ts
506
572
  var DEFAULT_MARKER = "<!-- code-review -->";
507
573
  var MAX_SUGGESTION_LINES = 10;
508
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
509
574
  var countSuggestionLines = (text) => text.split("\n").length;
510
575
  var checkLongSuggestions = (comments) => {
511
576
  const longFiles = [];
@@ -525,7 +590,6 @@ var checkLongSuggestions = (comments) => {
525
590
  });
526
591
  return { comments: adjusted, longFiles };
527
592
  };
528
- var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
529
593
  var noticeFindings = (message) => ({
530
594
  schema_version: DEFAULT_SCHEMA_VERSION,
531
595
  summary: `### \u26A0\uFE0F ${message}`,
@@ -593,9 +657,19 @@ var loadTestReport = (path) => {
593
657
  }
594
658
  return decoded.right;
595
659
  };
596
- 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.`;
597
671
  const body = JSON.stringify({
598
- body: "",
672
+ body: pointer,
599
673
  commit_id: headSha,
600
674
  event: "COMMENT",
601
675
  comments: comments.map((c) => ({
@@ -603,10 +677,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
603
677
  line: c.line,
604
678
  side: c.side,
605
679
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
606
- body: c.body
680
+ body: formatMarkdown(c.body)
607
681
  }))
608
682
  });
609
- 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);
610
688
  };
611
689
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
612
690
  const stdout = await ghApi(
@@ -626,33 +704,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
626
704
  const parsed = JSON.parse(last);
627
705
  return { id: parsed.id, body: parsed.body };
628
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
+ };
629
715
  var patchComment = async (repo, commentId, body, ghApi) => {
630
- await ghApi(
716
+ const stdout = await ghApi(
631
717
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
632
718
  JSON.stringify({ body })
633
719
  );
720
+ const htmlUrl = parseHtmlUrl(stdout);
721
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
634
722
  };
635
723
  var postComment = async (repo, prNumber, body, ghApi) => {
636
- await ghApi(
724
+ const stdout = await ghApi(
637
725
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
638
726
  JSON.stringify({ body })
639
727
  );
728
+ return parseCommentRef(stdout);
640
729
  };
641
730
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
642
731
  if (existing !== null) {
643
- await patchComment(repo, existing.id, body, ghApi);
732
+ const patched = await patchComment(repo, existing.id, body, ghApi);
644
733
  process.stderr.write(
645
734
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
646
735
  `
647
736
  );
648
- } else {
649
- await postComment(repo, prNumber, body, ghApi);
650
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
651
- `);
737
+ return { id: existing.id, url: patched?.html_url };
652
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;
653
743
  };
654
744
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
655
- var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
745
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
656
746
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
657
747
  let reviews;
658
748
  try {
@@ -661,10 +751,12 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
661
751
  return [];
662
752
  }
663
753
  if (!Array.isArray(reviews)) return [];
664
- return reviews.filter((r) => isBotReview(r)).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => r.id);
754
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
755
+ id: r.id,
756
+ commitId: typeof r.commit_id === "string" ? r.commit_id : ""
757
+ }));
665
758
  };
666
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
667
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
759
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
668
760
  for (const id of ids) {
669
761
  try {
670
762
  await ghApi(
@@ -709,8 +801,6 @@ var post = async (input, ghApi = runGhApi) => {
709
801
  DEFAULT_MARKER,
710
802
  ghApi
711
803
  );
712
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
713
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
714
804
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
715
805
  const decodedPrices = PriceMapCodec.decode(prices);
716
806
  if (decodedPrices._tag === "Left") {
@@ -718,15 +808,19 @@ var post = async (input, ghApi = runGhApi) => {
718
808
  }
719
809
  const template = readFileSync(input.templatePath, "utf-8");
720
810
  const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
721
- const renderNotice = (message) => render({
722
- findings: noticeFindings(message),
723
- envelope: null,
724
- prices: decodedPrices.right,
725
- template,
726
- route: input.route,
727
- reviewedSha: input.headSha,
728
- effort: input.effort
729
- });
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
+ );
730
824
  if (isEmptyDiff(diff)) {
731
825
  await upsertSticky(
732
826
  input.repo,
@@ -752,27 +846,32 @@ var post = async (input, ghApi = runGhApi) => {
752
846
  const envelope = loadEnvelope(input.envelopePath);
753
847
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
754
848
  if (envelope === null) {
755
- const body2 = render({
756
- findings,
757
- envelope: null,
758
- prices: decodedPrices.right,
759
- template,
760
- route: input.route,
761
- reviewedSha: input.headSha,
762
- effort: input.effort,
763
- testReport
764
- });
765
- 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);
766
865
  process.stderr.write(
767
866
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
768
867
  );
769
868
  process.exit(0);
770
869
  }
771
- const { comments: rawComments, strays } = buildInlineComments(
772
- findings.findings,
773
- diff,
774
- inlineTemplate
775
- );
870
+ const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
871
+ inlineTemplate,
872
+ models: envelope.models.map((m) => m.model),
873
+ jsonUrl: input.jsonUrl
874
+ });
776
875
  const { comments, longFiles } = checkLongSuggestions(rawComments);
777
876
  for (const wf of longFiles) {
778
877
  process.stderr.write(
@@ -780,7 +879,10 @@ var post = async (input, ghApi = runGhApi) => {
780
879
  `
781
880
  );
782
881
  }
783
- let body = render({
882
+ const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
883
+ 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;
885
+ const commonRenderInput = {
784
886
  findings,
785
887
  envelope,
786
888
  prices: decodedPrices.right,
@@ -788,35 +890,56 @@ var post = async (input, ghApi = runGhApi) => {
788
890
  route: input.route,
789
891
  reviewedSha: input.headSha,
790
892
  effort: input.effort,
791
- testReport
792
- });
793
- const straysMd = renderStraysSection(strays);
794
- if (straysMd.length > 0) body += straysMd;
795
- if (longFiles.length > 0) {
796
- body += `
893
+ testReport,
894
+ severityCounts: computeSeverityCounts(findings.findings),
895
+ strays,
896
+ inlineDisposition,
897
+ runUrl: input.runUrl,
898
+ jsonUrl: input.jsonUrl
899
+ };
900
+ const longFilesNote = longFiles.length > 0 ? `
797
901
 
798
902
  ---
799
903
 
800
- > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments. See the findings above for details.
801
- `;
802
- }
803
- if (!isRerunOfSameSha && previousReviewedSha !== null) {
804
- await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
805
- }
806
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
807
- if (isRerunOfSameSha) {
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.
905
+ ` : "";
906
+ const renderBody = (reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, reviewUrl: reviewUrl2 }) + longFilesNote);
907
+ const stickyRef = await upsertSticky(input.repo, prNumber, existingSticky, renderBody(), ghApi);
908
+ if (comments.length === 0) return;
909
+ if (alreadyReviewedThisSha) {
808
910
  process.stderr.write(
809
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
911
+ `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new inline review
810
912
  `
811
913
  );
812
914
  return;
813
915
  }
814
- if (comments.length > 0) {
815
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
816
- process.stderr.write(
817
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
916
+ const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
917
+ if (stalePriorReviewIds.length > 0) {
918
+ await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
919
+ }
920
+ const reviewUrl = await postInlineReview(
921
+ input.repo,
922
+ prNumber,
923
+ input.headSha,
924
+ comments,
925
+ stickyRef?.url,
926
+ ghApi
927
+ );
928
+ process.stderr.write(
929
+ `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
818
930
  `
819
- );
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
+ }
820
943
  }
821
944
  };
822
945
  var renderOutputs = (result) => {
@@ -1028,7 +1151,7 @@ var candidateFromJsonText = (kind, text) => {
1028
1151
  };
1029
1152
  var FENCE_OPEN = /^\s*(`{3,})/;
1030
1153
  var FENCE_MARKER_ONLY = /^`+$/;
1031
- var scanLine = (state, line) => {
1154
+ var scanLine2 = (state, line) => {
1032
1155
  if (state.openLength === null) {
1033
1156
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1034
1157
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1037,7 +1160,7 @@ var scanLine = (state, line) => {
1037
1160
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1038
1161
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1039
1162
  };
1040
- 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;
1041
1164
  var ladderFailureDiagnostics = (input) => {
1042
1165
  const native = parseNativeForExtraction(input.native);
1043
1166
  const preview = (s) => {
@@ -1172,6 +1295,83 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1172
1295
  }
1173
1296
  };
1174
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
+
1175
1375
  // src/index.ts
1176
1376
  var readJSON = (path) => {
1177
1377
  try {
@@ -1301,7 +1501,7 @@ var inlineCmd = defineCommand({
1301
1501
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1302
1502
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1303
1503
  const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1304
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
1504
+ const { comments, strays } = buildInlineComments(findings.findings, diff, { inlineTemplate });
1305
1505
  process.stdout.write(
1306
1506
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1307
1507
  );
@@ -1471,6 +1671,72 @@ ${ladderFailureDiagnostics(input)}
1471
1671
  fail(describeLadderFailure(outcome));
1472
1672
  }
1473
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
+ });
1474
1740
  var requireAdapterName = (name) => {
1475
1741
  if (isAdapterName(name)) return name;
1476
1742
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1622,6 +1888,14 @@ var postCmd = defineCommand({
1622
1888
  "test-report": {
1623
1889
  type: "string",
1624
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"
1625
1899
  }
1626
1900
  },
1627
1901
  run: async ({ args }) => {
@@ -1637,7 +1911,9 @@ var postCmd = defineCommand({
1637
1911
  route: args.route,
1638
1912
  headBranch: args["head-branch"],
1639
1913
  effort: args.effort,
1640
- testReportPath: args["test-report"]
1914
+ testReportPath: args["test-report"],
1915
+ runUrl: args["run-url"],
1916
+ jsonUrl: args["json-url"]
1641
1917
  });
1642
1918
  }
1643
1919
  });
@@ -1645,7 +1921,7 @@ var main = defineCommand({
1645
1921
  meta: {
1646
1922
  name: "code-review",
1647
1923
  version: packageVersion,
1648
- 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"
1649
1925
  },
1650
1926
  subCommands: {
1651
1927
  gather: gatherCmd,
@@ -1656,6 +1932,7 @@ var main = defineCommand({
1656
1932
  validate: validateCmd,
1657
1933
  adapt: adaptCmd,
1658
1934
  extract: extractCmd,
1935
+ "lower-suggestions": lowerSuggestionsCmd,
1659
1936
  "print-schema": printSchemaCmd
1660
1937
  }
1661
1938
  });