@expo/code-review-cli 0.7.0 → 0.9.0

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.
Files changed (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -1,5 +1,6 @@
1
+ // @ref LLP 0005#comment-rendering — pure Markdown builder; the comment body is the durable state store
1
2
  import { createHash } from "node:crypto";
2
- import { fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK } from "./schema.js";
3
+ import { collectPins, FeedbackPinSchema, FeedbackRecordSchema, fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK, } from "./schema.js";
3
4
  /**
4
5
  * Build the file → right-side-line-numbers index from changed files' patch text,
5
6
  * by walking each unified-diff hunk (`@@ -a,b +c,d @@`) and collecting the new-tree
@@ -62,9 +63,44 @@ export function groupBySeverity(findings) {
62
63
  export function commentMarker(tag) {
63
64
  return `<!-- ${tag} -->`;
64
65
  }
66
+ // @ref LLP 0011#forged-state-markers [implements] — the first-match parsers make an earlier forged marker win, so untrusted prose never keeps a raw `<!--`
67
+ /**
68
+ * Neutralize anything that could impersonate this reviewer's embedded state
69
+ * comment. `parseReviewState` and `parseEmbeddedFingerprints` match with a
70
+ * non-global RegExp, so they take the FIRST marker in the body while the genuine
71
+ * one is appended LAST: a forged `<!-- tag:state=… -->` rendered earlier — inside
72
+ * a model-written rationale, a path, a dismissal reason — would win over the real
73
+ * state and let PR content dictate the dismissal list. None of that prose ever
74
+ * legitimately needs an HTML comment, so every `<!--` in it is escaped.
75
+ */
76
+ export function stripStateMarkers(text) {
77
+ return text.replace(/<!--/g, "&lt;!--");
78
+ }
79
+ /** GitHub logins are `[A-Za-z0-9-]`, 39 chars max — anything else is forged. */
80
+ const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
81
+ /** A reply link must be a github.com URL, so a record can't inject an arbitrary
82
+ * target into the body. */
83
+ const REPLY_URL_RE = /^https:\/\/github\.com\/[\w.\-/#]+$/;
84
+ // @ref LLP 0011#never-echo-reply-text [constrained-by] — only the login and the link are rendered, both validated; a login that isn't GitHub-shaped names no one
85
+ /**
86
+ * `@login`, or `an author` when the login isn't GitHub-shaped. The reply's own
87
+ * text is never rendered, so this credit line is the whole of what a reply
88
+ * contributes to the body.
89
+ */
90
+ function replyAuthor(record) {
91
+ return LOGIN_RE.test(record.by) ? `@${record.by}` : "an author";
92
+ }
93
+ /** Link `text` to the reply comment, or leave it plain when the URL doesn't
94
+ * validate. */
95
+ function replyLink(text, record) {
96
+ return record.url && REPLY_URL_RE.test(record.url) ? `[${text}](${record.url})` : text;
97
+ }
65
98
  function locationText(finding) {
66
- return finding.line != null ? `${finding.file}:${finding.line}` : finding.file;
99
+ // A git path may legally hold anything, including a forged state marker.
100
+ const file = stripStateMarkers(finding.file);
101
+ return finding.line != null ? `${file}:${finding.line}` : file;
67
102
  }
103
+ // @ref LLP 0005#comment-rendering [implements] — diff anchor only if the line is in the diff; else base-SHA blob (f9fecd5)
68
104
  /**
69
105
  * Render a finding's location as inline code, linked to the code it points at:
70
106
  * - in the diff (file+line shown in a hunk) → the PR's "Files changed" tab at that
@@ -92,7 +128,7 @@ function location(finding, link) {
92
128
  }
93
129
  if (link.baseSha) {
94
130
  const lineAnchor = finding.line != null ? `#L${finding.line}` : "";
95
- const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${finding.file}${lineAnchor}`;
131
+ const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${stripStateMarkers(finding.file)}${lineAnchor}`;
96
132
  return `[\`${text}\`](${url})`;
97
133
  }
98
134
  return `\`${text}\``;
@@ -102,29 +138,41 @@ function location(finding, link) {
102
138
  * per-PR dismissals. Findings whose fingerprint appears in `dismissed` render in a
103
139
  * collapsed "Dismissed" section instead of the main list.
104
140
  */
105
- export function renderMarkdown(review, tag, dismissed = [], link) {
141
+ export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = []) {
106
142
  const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
107
143
  const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
108
- const kept = withFp.filter(({ fp }) => !dismissedByFp.has(fp));
109
- const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
144
+ const feedbackByFp = matchedFeedback(feedback, new Set(withFp.map((entry) => entry.fp)));
145
+ // An applied reply cleared the finding, so it leaves the active list exactly
146
+ // like a dismissal — with its own audit line saying a reply is what did it.
147
+ const isDropped = ({ fp }) => dismissedByFp.has(fp) || feedbackByFp.get(fp)?.applied === true;
148
+ const notDismissed = withFp.filter((entry) => !isDropped(entry));
149
+ const dropped = withFp.filter((entry) => isDropped(entry));
150
+ // A requalified finding is addressed by a stacked PR: shown in its own collapsed
151
+ // section and counted, but never in the main (blocking) severity list.
152
+ const kept = notDismissed.filter(({ finding }) => !finding.requalifiedBy);
153
+ const requalified = notDismissed.filter(({ finding }) => finding.requalifiedBy);
110
154
  const lines = [commentMarker(tag), "## 🤖 AI code review", ""];
111
- lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", review.summary, "");
155
+ lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", stripStateMarkers(review.summary), "");
112
156
  if (review.incomplete.length > 0) {
113
- lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
157
+ lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${stripStateMarkers(note)}`), "");
114
158
  }
159
+ lines.push(...setupNote(review.setupNotes));
160
+ lines.push(...requalificationAuditNote(requalified.map((entry) => entry.finding)));
161
+ lines.push(...feedbackAuditNote([...feedbackByFp.values()]));
115
162
  if (kept.length === 0) {
116
163
  lines.push("No findings.", "");
117
164
  }
118
165
  else {
119
- lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link));
166
+ lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link, fingerprintFinding, feedbackByFp));
167
+ }
168
+ if (requalified.length > 0) {
169
+ lines.push("<details>", `<summary>🔁 Addressed in stacked PRs (${requalified.length})</summary>`, "", ...requalified.flatMap(({ finding, fp }) => addressedLines(finding, fp, link)), "</details>", "");
120
170
  }
121
171
  if (dropped.length > 0) {
122
172
  lines.push("<details>", `<summary>🚫 Dismissed on this PR (${dropped.length})</summary>`, "");
123
173
  for (const { finding, fp } of dropped) {
124
- const record = dismissedByFp.get(fp);
125
- const who = record.by ? ` by @${record.by}` : "";
126
- const why = record.reason ? ` — ${record.reason}` : "";
127
- lines.push(`- **${finding.title}** — ${location(finding, link)} \`id:${fp}\`${who}${why}`);
174
+ const suffix = droppedSuffix(dismissedByFp.get(fp), feedbackByFp.get(fp));
175
+ lines.push(`- **${stripStateMarkers(finding.title)}** ${location(finding, link)} \`id:${fp}\`${suffix}`);
128
176
  }
129
177
  lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
130
178
  }
@@ -133,7 +181,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
133
181
  // and dismissals, so `/dismiss` can re-render this comment without re-running.
134
182
  const fingerprints = review.findings.map(fingerprintFinding);
135
183
  lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
136
- lines.push(`<!-- ${tag}:state=${encodeState({ review, dismissed })} -->`);
184
+ lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed }, feedbackByFp, pins))} -->`);
137
185
  return lines.join("\n");
138
186
  }
139
187
  /**
@@ -141,7 +189,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
141
189
  * aggregate renderers. `idFor` supplies each finding's id (default:
142
190
  * fingerprintFinding); the aggregate renderer passes a scope-namespaced id.
143
191
  */
144
- function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
192
+ function renderSeveritySections(findings, link, idFor = fingerprintFinding, feedbackById) {
145
193
  const out = [];
146
194
  const groups = groupBySeverity(sortFindings(findings));
147
195
  for (const severity of SEVERITIES) {
@@ -151,12 +199,14 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
151
199
  }
152
200
  out.push(`### ${severityHeading(severity)} (${group.length})`, "");
153
201
  for (const finding of group) {
154
- out.push(...renderFindingLines(finding, link, idFor(finding)));
202
+ const id = idFor(finding);
203
+ out.push(...renderFindingLines(finding, link, id, feedbackById?.get(id)));
155
204
  }
156
205
  out.push("");
157
206
  }
158
207
  return out;
159
208
  }
209
+ // @ref LLP 0005#comment-rendering [constrained-by] — blank lines must stay truly empty or <details> escapes the list (euxy#45)
160
210
  /**
161
211
  * Indent every line of a multi-line value to a list item's content column.
162
212
  *
@@ -173,19 +223,116 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
173
223
  function indentContinuation(value, indent = " ") {
174
224
  return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
175
225
  }
176
- function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
226
+ function renderFindingLines(finding, link, id = fingerprintFinding(finding), reply) {
177
227
  const out = [
178
- `- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\``,
179
- ...indentContinuation(finding.rationale),
228
+ `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
229
+ ...indentContinuation(stripStateMarkers(finding.rationale)),
180
230
  ];
181
231
  if (finding.suggestion) {
182
- out.push(...indentContinuation(`_Suggestion:_ ${finding.suggestion}`));
232
+ out.push(...indentContinuation(`_Suggestion:_ ${stripStateMarkers(finding.suggestion)}`));
183
233
  }
184
234
  // Separator so a rationale ending in `</details>` cannot swallow the next
185
235
  // bullet. Findings are already loose list items, so this changes no spacing.
186
236
  out.push("");
187
237
  return out;
188
238
  }
239
+ // @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
240
+ /** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
241
+ function setupNote(notes = []) {
242
+ if (notes.length === 0) {
243
+ return [];
244
+ }
245
+ return ["> 🔗 **Review setup:**", ...notes.map((note) => `> - ${stripStateMarkers(note)}`), ""];
246
+ }
247
+ // @ref LLP 0010#rendering-in-all-three-paths [implements] — the visible audit count is mandatory: requalification's only effect on a real finding is moving it out of the blocking set, so it must never be silent
248
+ /**
249
+ * The visible one-line audit note in the OPEN body, naming the addressing PRs. This
250
+ * is what keeps requalification from being a "collapsed fold nobody reads": the
251
+ * count and PR numbers show above the fold. Empty when nothing was requalified.
252
+ */
253
+ function requalificationAuditNote(requalified) {
254
+ if (requalified.length === 0) {
255
+ return [];
256
+ }
257
+ const prNumbers = [
258
+ ...new Set(requalified
259
+ .map((finding) => finding.requalifiedBy?.prNumber)
260
+ .filter((n) => n != null)),
261
+ ].sort((a, b) => a - b);
262
+ const prList = prNumbers.map((n) => `#${n}`).join(", ");
263
+ return [
264
+ `> 🔁 **${requalified.length} finding(s)** marked addressed by stacked PR(s) (${prList}); ` +
265
+ "excluded from the decision but shown below.",
266
+ "",
267
+ ];
268
+ }
269
+ /** One bullet per finding in the "Addressed in stacked PRs" section — names the
270
+ * addressing PR and the exact upstack path relied on. */
271
+ function addressedLines(finding, fp, link) {
272
+ const requalified = finding.requalifiedBy;
273
+ const reason = requalified.reason ? `: ${stripStateMarkers(requalified.reason)}` : "";
274
+ return [
275
+ `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} \`id:${fp}\` — addressed in ` +
276
+ `#${requalified.prNumber} (\`${stripStateMarkers(requalified.file)}\`)${reason}`,
277
+ ];
278
+ }
279
+ /**
280
+ * Index the feedback records that answer a finding actually present in this
281
+ * comment, keyed by the id the comment renders. A record whose finding is gone
282
+ * (the flagged code changed, so the fingerprint moved) is dropped: it can no
283
+ * longer be shown, counted, or audited, so carrying it forward only grows state.
284
+ */
285
+ function matchedFeedback(feedback, ids) {
286
+ const matched = new Map();
287
+ for (const record of feedback) {
288
+ if (ids.has(record.fp) && !matched.has(record.fp)) {
289
+ matched.set(record.fp, record);
290
+ }
291
+ }
292
+ return matched;
293
+ }
294
+ // @ref LLP 0011#suppression-is-never-silent [implements] — the count sits above the fold, exactly like the requalification note, so a reply clearing a finding is never a quiet fold nobody opens
295
+ /** The visible one-line audit note for author responses. Empty when no reply
296
+ * matched a finding in this comment. */
297
+ function feedbackAuditNote(records) {
298
+ if (records.length === 0) {
299
+ return [];
300
+ }
301
+ const applied = records.filter((record) => record.applied).length;
302
+ return [
303
+ `> 💬 **${records.length} finding(s)** have an author response (${applied} applied).`,
304
+ "",
305
+ ];
306
+ }
307
+ /** ` · 💬 [@login replied](url)` on an active finding — the entire visible trace
308
+ * of a reply. The reply's own text is never part of it. */
309
+ function replyAnnotation(record) {
310
+ return record ? ` · 💬 ${replyLink(`${replyAuthor(record)} replied`, record)}` : "";
311
+ }
312
+ /** The audit tail of a "Dismissed" bullet: an explicit `/dismiss`, or the reply
313
+ * that cleared the finding. */
314
+ function droppedSuffix(dismissal, reply) {
315
+ if (dismissal) {
316
+ const who = dismissal.by ? ` by @${stripStateMarkers(dismissal.by)}` : "";
317
+ const why = dismissal.reason ? ` — ${stripStateMarkers(dismissal.reason)}` : "";
318
+ return `${who}${why}`;
319
+ }
320
+ return reply ? ` — dismissed via reply by ${replyLink(replyAuthor(reply), reply)}` : "";
321
+ }
322
+ // @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin set rides the state on EVERY render, independent of which replies matched this run, so a reply that disappears can never drop a maintainer's restore
323
+ /** Attach the matched feedback to the state blob. It rides the embedded state
324
+ * like dismissals do, so the next run re-reads what was recorded (including a
325
+ * verdict already decided) instead of re-deriving it.
326
+ *
327
+ * `pins` is written whole and unfiltered — NOT indexed by the findings or the records
328
+ * this render happens to show. A pin is a maintainer's decision about a finding, so it
329
+ * must outlive a run where the reply was edited away, the record was dropped, or the
330
+ * finding itself was not re-emitted. */
331
+ function reviewState(state, feedbackByFp, pins = []) {
332
+ const feedback = [...feedbackByFp.values()];
333
+ const withFeedback = feedback.length > 0 ? { ...state, feedback } : state;
334
+ return pins.length > 0 ? { ...withFeedback, pins } : withFeedback;
335
+ }
189
336
  /** Parse the fingerprints embedded in a previously-posted comment body. */
190
337
  export function parseEmbeddedFingerprints(body, tag) {
191
338
  // Escape the (config-controlled) tag so regex metacharacters can't break the match.
@@ -213,6 +360,7 @@ export function worstDecision(decisions) {
213
360
  }
214
361
  /** GitHub's comment body limit is ~65k chars; keep a margin. */
215
362
  const MAX_COMMENT_CHARS = 60_000;
363
+ // @ref LLP 0005#truncation-and-aggregate-state [constrained-by] — truncation trims shown findings only; dismissed findings always kept in state
216
364
  /**
217
365
  * One aggregated comment under the single existing marker: a scope summary table,
218
366
  * an optional coverage block, one <details> per scope (findings rendered with
@@ -221,19 +369,33 @@ const MAX_COMMENT_CHARS = 60_000;
221
369
  * carries the real per-scope data. Oversized bodies trim each scope's findings to
222
370
  * the most severe N (halving until it fits, floor 3) with a per-scope note.
223
371
  */
224
- export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
372
+ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, feedback = [], pins = []) {
225
373
  const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
226
374
  const idOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
227
- // Split each scope's findings into kept/dropped once (dismissal is limit-independent).
375
+ // Feedback is keyed by the SAME scope-namespaced id the comment renders, so a
376
+ // record can never cross scopes.
377
+ const feedbackById = matchedFeedback(feedback, new Set(results.flatMap((result) => result.review.findings.map((f) => idOf(result, f)))));
378
+ // Split each scope's findings into kept/requalified/dropped once (dismissal and
379
+ // requalification are both limit-independent). `kept` is the active/blocking set;
380
+ // requalified findings are addressed by a stacked PR — counted and shown, never
381
+ // in the blocking list.
228
382
  const perScope = results.map((result) => {
229
383
  const withId = result.review.findings.map((finding) => ({
230
384
  finding,
231
385
  id: idOf(result, finding),
232
386
  }));
387
+ // An applied reply drops a finding out of the active list, like a dismissal.
388
+ const isDropped = (entry) => dismissedByFp.has(entry.id) || feedbackById.get(entry.id)?.applied === true;
389
+ const notDismissed = withId.filter((entry) => !isDropped(entry));
233
390
  return {
234
391
  result,
235
- kept: withId.filter((entry) => !dismissedByFp.has(entry.id)),
236
- dropped: withId.filter((entry) => dismissedByFp.has(entry.id)),
392
+ kept: notDismissed.filter((entry) => !entry.finding.requalifiedBy),
393
+ requalified: notDismissed.filter((entry) => entry.finding.requalifiedBy),
394
+ dropped: withId.filter((entry) => isDropped(entry)),
395
+ // The scope's own author responses, for its audit note.
396
+ feedback: withId
397
+ .map((entry) => feedbackById.get(entry.id))
398
+ .filter((record) => record != null),
237
399
  };
238
400
  });
239
401
  const worst = worstDecision(results.map((result) => result.review.decision));
@@ -249,7 +411,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
249
411
  "| --- | --- | --- |",
250
412
  ];
251
413
  for (const { result, kept } of perScope) {
252
- lines.push(`| ${result.scope} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
414
+ lines.push(`| ${stripStateMarkers(result.scope)} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
253
415
  }
254
416
  lines.push("");
255
417
  const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
@@ -258,54 +420,77 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
258
420
  if (unmatched.length > 0) {
259
421
  const shown = unmatched
260
422
  .slice(0, 10)
261
- .map((file) => `\`${file}\``)
423
+ .map((file) => `\`${stripStateMarkers(file)}\``)
262
424
  .join(", ");
263
425
  const more = unmatched.length > 10 ? `, …(+${unmatched.length - 10} more)` : "";
264
426
  lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
265
427
  }
266
428
  for (const result of results) {
267
429
  for (const note of result.review.incomplete) {
268
- lines.push(`> - [${result.scope}] ${note}`);
430
+ lines.push(`> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`);
269
431
  }
270
432
  }
271
433
  lines.push("");
272
434
  }
435
+ const setupLines = results.flatMap((result) => (result.review.setupNotes ?? []).map((note) => `> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`));
436
+ if (setupLines.length > 0) {
437
+ lines.push("> 🔗 **Review setup:**", ...setupLines, "");
438
+ }
273
439
  // Shown = the most-severe N kept findings per scope (N = limitPerScope). The
274
440
  // embedded state trims KEPT findings to the same set so a truncated comment
275
441
  // still fits GitHub's body limit (the hidden findings are noted, not silently
276
442
  // carried) — but dismissed findings are always kept in state (see below).
277
- const rendered = perScope.map(({ result, kept, dropped }) => ({
443
+ const rendered = perScope.map(({ result, kept, requalified, dropped, feedback: replies }) => ({
278
444
  result,
445
+ replies,
279
446
  shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
280
447
  hidden: Math.max(0, kept.length - limitPerScope),
448
+ // The requalified section is trimmed by the same per-scope limit as shown: it
449
+ // is coordinator-populated (a wide stack can requalify many findings at once),
450
+ // and an untrimmed section would keep the truncation loop below from ever
451
+ // converging under MAX_COMMENT_CHARS. The audit note carries the TOTAL count,
452
+ // so trimming never hides that requalification happened.
453
+ requalified: requalified.slice(0, limitPerScope),
454
+ requalifiedHidden: Math.max(0, requalified.length - limitPerScope),
455
+ requalifiedAll: requalified,
281
456
  dropped,
282
457
  }));
283
- for (const { result, shown, hidden } of rendered) {
284
- const open = shown.length > 0 ? " open" : "";
458
+ for (const { result, replies, shown, hidden, requalified, requalifiedAll, requalifiedHidden, } of rendered) {
459
+ // @ref LLP 0011#suppression-is-never-silent a reply-suppressed scope opens
460
+ // its fold so the audit note is visible, matching renderMarkdown and LLP 0010's
461
+ // visible-suppression rule (else a scope replies cleared reads as clean).
462
+ const open = shown.length > 0 || requalifiedAll.length > 0 || replies.length > 0 ? " open" : "";
285
463
  const keptCount = shown.length + hidden;
286
- lines.push(`<details${open}>`, `<summary>${result.scope} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
464
+ lines.push(`<details${open}>`, `<summary>${stripStateMarkers(result.scope)} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
287
465
  if (result.review.summary) {
288
- lines.push(result.review.summary, "");
466
+ lines.push(stripStateMarkers(result.review.summary), "");
289
467
  }
468
+ lines.push(...requalificationAuditNote(requalifiedAll.map((entry) => entry.finding)));
469
+ lines.push(...feedbackAuditNote(replies));
290
470
  if (shown.length === 0) {
291
471
  lines.push("No findings.", "");
292
472
  }
293
473
  else {
294
- lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
474
+ lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding), feedbackById));
295
475
  }
296
476
  if (hidden > 0) {
297
477
  lines.push(`_…and ${hidden} more finding(s) — see the workflow log._`, "");
298
478
  }
479
+ if (requalifiedAll.length > 0) {
480
+ lines.push(`**🔁 Addressed in stacked PRs (${requalifiedAll.length})**`, "", ...requalified.flatMap((entry) => addressedLines(entry.finding, entry.id, link)));
481
+ if (requalifiedHidden > 0) {
482
+ lines.push(`_…and ${requalifiedHidden} more addressed finding(s) — see the workflow log._`);
483
+ }
484
+ lines.push("");
485
+ }
299
486
  lines.push("</details>", "");
300
487
  }
301
488
  const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
302
489
  if (allDropped.length > 0) {
303
490
  lines.push("<details>", `<summary>🚫 Dismissed on this PR (${allDropped.length})</summary>`, "");
304
491
  for (const { finding, id, scope } of allDropped) {
305
- const record = dismissedByFp.get(id);
306
- const who = record.by ? ` by @${record.by}` : "";
307
- const why = record.reason ? ` — ${record.reason}` : "";
308
- lines.push(`- **${finding.title}** — ${location(finding, link)} \`id:${id}\` _(${scope})_${who}${why}`);
492
+ const suffix = droppedSuffix(dismissedByFp.get(id), feedbackById.get(id));
493
+ lines.push(`- **${stripStateMarkers(finding.title)}** ${location(finding, link)} \`id:${id}\` _(${stripStateMarkers(scope)})_${suffix}`);
309
494
  }
310
495
  lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
311
496
  }
@@ -316,11 +501,30 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
316
501
  // review) so /undismiss can restore them and the Dismissed section persists
317
502
  // across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
318
503
  // keep both v2 and v1 consumers working.
319
- const stateScopes = rendered.map(({ result, shown, dropped }) => ({
320
- scope: result.scope,
321
- isDefault: result.isDefault,
322
- review: { ...result.review, findings: [...shown, ...dropped.map((entry) => entry.finding)] },
323
- }));
504
+ const stateScopes = rendered.map(({ result, shown, requalified, dropped }) => {
505
+ // @ref LLP 0011#suppression-is-never-silent — strip any per-scope `feedback`
506
+ // a freshly-reviewed scope's ReviewRunResult carries: the top-level feedback
507
+ // array (reviewState below) is the single source of truth. A stale per-scope
508
+ // copy that survived here would be read back on a later carried-over run and
509
+ // could re-apply a record a human /undismiss already overrode at the top level.
510
+ const { feedback: _feedback, ...review } = result.review;
511
+ return {
512
+ scope: result.scope,
513
+ isDefault: result.isDefault,
514
+ // Requalified findings ride the embedded state (like dismissed ones) so a
515
+ // re-render (/dismiss) round-trips them and the addressed section persists.
516
+ // Under truncation they are trimmed exactly like `shown` — state bytes count
517
+ // toward the comment size, so an untrimmed list would defeat the cap loop.
518
+ review: {
519
+ ...review,
520
+ findings: [
521
+ ...shown,
522
+ ...requalified.map((entry) => entry.finding),
523
+ ...dropped.map((entry) => entry.finding),
524
+ ],
525
+ },
526
+ };
527
+ });
324
528
  const merged = {
325
529
  decision: worst,
326
530
  findings: stateScopes.flatMap((scope) => scope.review.findings),
@@ -331,12 +535,17 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
331
535
  };
332
536
  const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
333
537
  lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
334
- lines.push(`<!-- ${tag}:state=${encodeState({ review: merged, dismissed, scopes: stateScopes })} -->`);
538
+ // Feedback records and `/undismiss` pins ride the state whole, never trimmed by the
539
+ // cap loop: each is a handful of bytes, and losing one would lose a verdict already
540
+ // decided — or a maintainer's restore, which no later run could recover.
541
+ lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review: merged, dismissed, scopes: stateScopes }, feedbackById, pins))} -->`);
335
542
  return lines.join("\n");
336
543
  };
337
544
  let limit = Number.POSITIVE_INFINITY;
338
545
  let body = buildBody(limit);
339
- const largestScope = Math.max(0, ...perScope.map((entry) => entry.kept.length));
546
+ // Seed from the largest per-scope section the limit applies to — kept OR
547
+ // requalified — so the halving loop shrinks whichever one is oversized.
548
+ const largestScope = Math.max(0, ...perScope.map((entry) => Math.max(entry.kept.length, entry.requalified.length)));
340
549
  while (body.length > MAX_COMMENT_CHARS && limit > 3) {
341
550
  limit =
342
551
  limit === Number.POSITIVE_INFINITY
@@ -359,7 +568,21 @@ export function parseReviewState(body, tag) {
359
568
  try {
360
569
  const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
361
570
  if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
362
- return parsed;
571
+ // The v3 `feedback` field is shape-validated rather than trusted: it feeds
572
+ // the blocking decision, so a malformed blob must yield no records, not
573
+ // junk ones. Same for the v4 `pins`.
574
+ const feedback = FeedbackRecordSchema.array().safeParse(parsed.feedback ?? []);
575
+ const records = feedback.success ? feedback.data : [];
576
+ const parsedPins = FeedbackPinSchema.array().safeParse(parsed.pins ?? []);
577
+ // Migration on read: a v3 comment stored its pins on the records themselves, so
578
+ // collectPins lifts those into the set. Without this, the first render by this
579
+ // version would write a state with no pins at all and silently release every
580
+ // `/undismiss` a maintainer had already made.
581
+ return {
582
+ ...parsed,
583
+ feedback: records,
584
+ pins: collectPins(parsedPins.success ? parsedPins.data : [], records),
585
+ };
363
586
  }
364
587
  }
365
588
  catch {
@@ -0,0 +1,158 @@
1
+ /** Blockquote lines read per comment. A reply that quotes half the review is
2
+ * either noise or an attempt to match everything at once. */
3
+ const MAX_QUOTED_LINES = 50;
4
+ /**
5
+ * Minimum normalized length of a quoted line before it may match a title. A bare
6
+ * `> ok` or `> +1` must never collide with a short finding title.
7
+ */
8
+ const MIN_QUOTE_LEN = 8;
9
+ /** `id:<hex>` as it renders in the comment, in the fingerprint alphabet
10
+ * (`dismiss.ts` sanitizes user input to the same one). */
11
+ const FINDING_ID_RE = /\bid:([a-f0-9]{6,64})\b/gi;
12
+ /**
13
+ * Our own comment always ends with the embedded `:state=` / `:fingerprints=`
14
+ * markers. The caller passes only non-bot comments, but matching our own body
15
+ * would let the review answer itself, so this is the tag-independent backstop.
16
+ */
17
+ const OWN_COMMENT_RE = /<!--[^\n]*:(?:state|fingerprints)=/;
18
+ /**
19
+ * Markdown → comparable text: link text without the target, no backticks or
20
+ * emphasis marks, collapsed whitespace, lowercase, no trailing punctuation. A
21
+ * reply quoting a rendered finding title is byte-identical to it, so this only
22
+ * has to absorb the copy-paste noise around that.
23
+ */
24
+ export function normalizeTitle(text) {
25
+ return text
26
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
27
+ .replace(/[`*_~]/g, "")
28
+ .replace(/\s+/g, " ")
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/[.,;:!?]+$/, "")
32
+ .trim();
33
+ }
34
+ /**
35
+ * The blockquote lines (`> …`) of a comment body, in order and bounded. Fenced
36
+ * code blocks are skipped: a `>` inside a fence is quoted CODE, not the author
37
+ * quoting the review, and treating it as a quote would let a pasted snippet
38
+ * match a finding nobody replied to.
39
+ */
40
+ export function extractQuotedLines(body) {
41
+ const out = [];
42
+ let inFence = false;
43
+ for (const raw of body.split("\n")) {
44
+ if (/^\s*(?:```|~~~)/.test(raw)) {
45
+ inFence = !inFence;
46
+ continue;
47
+ }
48
+ if (inFence || !/^\s*>/.test(raw)) {
49
+ continue;
50
+ }
51
+ // Strip the marker plus any nesting (`>> quoted reply`).
52
+ const text = raw.replace(/^\s*>[\s>]*/, "").trim();
53
+ if (text === "") {
54
+ continue;
55
+ }
56
+ out.push(text);
57
+ if (out.length >= MAX_QUOTED_LINES) {
58
+ break;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ /** The `id:<hex>` tokens a comment cites, lowercased and deduped. */
64
+ export function extractFindingIds(body) {
65
+ const ids = [...body.matchAll(FINDING_ID_RE)].map((match) => match[1].toLowerCase());
66
+ return [...new Set(ids)];
67
+ }
68
+ // @ref LLP 0011#a-quote-annotates-an-id-clears [implements] — only an id the replier wrote OUTSIDE any blockquote counts as citing the finding
69
+ /**
70
+ * The `id:<hex>` tokens the commenter wrote THEMSELVES — an id inside a blockquote
71
+ * line does not count. GitHub's "Quote reply" copies the target comment verbatim with
72
+ * every line prefixed `> `, and the untrusted PR author controls that text, so a quoted
73
+ * title OR a quoted id may be text they planted. Only an unquoted id is a citation by
74
+ * the replier, which is what `feedbackApplied` requires before a reply may CLEAR a
75
+ * finding.
76
+ */
77
+ export function extractCitedFindingIds(body) {
78
+ const own = body
79
+ .split("\n")
80
+ .filter((line) => !/^\s*>/.test(line))
81
+ .join("\n");
82
+ return extractFindingIds(own);
83
+ }
84
+ // @ref LLP 0011#deterministic-matching [implements] — an ambiguous quote records nothing; only an id disambiguates
85
+ /**
86
+ * Match author replies to the findings they answer. Deterministic by design: a
87
+ * quoted line is compared to the rendered title, an `id:` token is compared to
88
+ * the known fingerprints, and nothing else counts — no model decides which
89
+ * finding a reply is about.
90
+ *
91
+ * An id in the known set wins outright and is the only way a quote shared by
92
+ * several findings resolves; a quoted line matching 2+ findings otherwise
93
+ * records NOTHING, because attributing pushback to the wrong finding is worse
94
+ * than recording no pushback at all. One record per finding: when several
95
+ * comments answer the same one, the newest (highest comment id) wins.
96
+ *
97
+ * `citedId` records HOW the reply names the finding: true only when the replier cited
98
+ * the finding's id in their own words. It is independent of `opts.match` — that knob
99
+ * selects how a reply MATCHES, while `citedId` is what `feedbackApplied` requires
100
+ * before a reply may CLEAR (see LLP 0011).
101
+ */
102
+ export function matchReplies(comments, findings, opts) {
103
+ const known = new Set(findings.map((entry) => entry.fp));
104
+ const byTitle = new Map();
105
+ for (const { finding, fp } of findings) {
106
+ const key = normalizeTitle(finding.title);
107
+ byTitle.set(key, [...(byTitle.get(key) ?? []), fp]);
108
+ }
109
+ const newest = new Map();
110
+ for (const comment of comments) {
111
+ if (OWN_COMMENT_RE.test(comment.body)) {
112
+ continue;
113
+ }
114
+ const matched = new Set();
115
+ // The ids this replier cited in their own words, computed whatever `match` says: a
116
+ // quote-matched reply still records WHETHER it cites the finding, because that is
117
+ // what decides clearing.
118
+ const cited = new Set(extractCitedFindingIds(comment.body));
119
+ if (opts.match !== "quote") {
120
+ for (const id of extractFindingIds(comment.body)) {
121
+ if (known.has(id)) {
122
+ matched.add(id);
123
+ }
124
+ }
125
+ }
126
+ if (opts.match !== "id") {
127
+ for (const quoted of extractQuotedLines(comment.body)) {
128
+ const key = normalizeTitle(quoted);
129
+ if (key.length < MIN_QUOTE_LEN) {
130
+ continue;
131
+ }
132
+ const candidates = byTitle.get(key);
133
+ // Ambiguous (2+ findings share this title): the only resolution is an id
134
+ // in the same comment, which the pass above already recorded.
135
+ if (candidates?.length === 1) {
136
+ matched.add(candidates[0]);
137
+ }
138
+ }
139
+ }
140
+ for (const fp of matched) {
141
+ const previous = newest.get(fp);
142
+ if (previous && previous.commentId >= comment.id) {
143
+ continue;
144
+ }
145
+ newest.set(fp, {
146
+ fp,
147
+ by: comment.login,
148
+ commentId: comment.id,
149
+ ...(comment.url ? { url: comment.url } : {}),
150
+ maintainer: comment.maintainer,
151
+ author: comment.author,
152
+ citedId: cited.has(fp),
153
+ applied: false,
154
+ });
155
+ }
156
+ }
157
+ return [...newest.values()].sort((a, b) => a.fp.localeCompare(b.fp));
158
+ }