@expo/code-review-cli 0.7.0 → 0.8.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 (54) hide show
  1. package/README.md +118 -13
  2. package/build/cli.js +7 -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/review.js +191 -51
  9. package/build/commands/setup-auth.js +3 -0
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +92 -0
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +5 -1
  16. package/build/core/claude-code.js +12 -1
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +4 -0
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +22 -0
  24. package/build/core/prompts.js +311 -3
  25. package/build/core/render.js +255 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +290 -15
  28. package/build/core/schema.js +213 -2
  29. package/build/core/scrub.js +4 -0
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +2 -0
  35. package/build/core/util.js +1 -0
  36. package/build/core/verify.js +5 -0
  37. package/build/reporters/github.js +465 -31
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +272 -0
  40. package/build/sources/local-git.js +3 -0
  41. package/build/sources/source.js +35 -0
  42. package/package.json +2 -1
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +50 -1
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +96 -1
  54. 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,40 @@ 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(...requalificationAuditNote(requalified.map((entry) => entry.finding)));
160
+ lines.push(...feedbackAuditNote([...feedbackByFp.values()]));
115
161
  if (kept.length === 0) {
116
162
  lines.push("No findings.", "");
117
163
  }
118
164
  else {
119
- lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link));
165
+ lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link, fingerprintFinding, feedbackByFp));
166
+ }
167
+ if (requalified.length > 0) {
168
+ lines.push("<details>", `<summary>🔁 Addressed in stacked PRs (${requalified.length})</summary>`, "", ...requalified.flatMap(({ finding, fp }) => addressedLines(finding, fp, link)), "</details>", "");
120
169
  }
121
170
  if (dropped.length > 0) {
122
171
  lines.push("<details>", `<summary>🚫 Dismissed on this PR (${dropped.length})</summary>`, "");
123
172
  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}`);
173
+ const suffix = droppedSuffix(dismissedByFp.get(fp), feedbackByFp.get(fp));
174
+ lines.push(`- **${stripStateMarkers(finding.title)}** ${location(finding, link)} \`id:${fp}\`${suffix}`);
128
175
  }
129
176
  lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
130
177
  }
@@ -133,7 +180,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
133
180
  // and dismissals, so `/dismiss` can re-render this comment without re-running.
134
181
  const fingerprints = review.findings.map(fingerprintFinding);
135
182
  lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
136
- lines.push(`<!-- ${tag}:state=${encodeState({ review, dismissed })} -->`);
183
+ lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed }, feedbackByFp, pins))} -->`);
137
184
  return lines.join("\n");
138
185
  }
139
186
  /**
@@ -141,7 +188,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
141
188
  * aggregate renderers. `idFor` supplies each finding's id (default:
142
189
  * fingerprintFinding); the aggregate renderer passes a scope-namespaced id.
143
190
  */
144
- function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
191
+ function renderSeveritySections(findings, link, idFor = fingerprintFinding, feedbackById) {
145
192
  const out = [];
146
193
  const groups = groupBySeverity(sortFindings(findings));
147
194
  for (const severity of SEVERITIES) {
@@ -151,12 +198,14 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
151
198
  }
152
199
  out.push(`### ${severityHeading(severity)} (${group.length})`, "");
153
200
  for (const finding of group) {
154
- out.push(...renderFindingLines(finding, link, idFor(finding)));
201
+ const id = idFor(finding);
202
+ out.push(...renderFindingLines(finding, link, id, feedbackById?.get(id)));
155
203
  }
156
204
  out.push("");
157
205
  }
158
206
  return out;
159
207
  }
208
+ // @ref LLP 0005#comment-rendering [constrained-by] — blank lines must stay truly empty or <details> escapes the list (euxy#45)
160
209
  /**
161
210
  * Indent every line of a multi-line value to a list item's content column.
162
211
  *
@@ -173,19 +222,108 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
173
222
  function indentContinuation(value, indent = " ") {
174
223
  return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
175
224
  }
176
- function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
225
+ function renderFindingLines(finding, link, id = fingerprintFinding(finding), reply) {
177
226
  const out = [
178
- `- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\``,
179
- ...indentContinuation(finding.rationale),
227
+ `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
228
+ ...indentContinuation(stripStateMarkers(finding.rationale)),
180
229
  ];
181
230
  if (finding.suggestion) {
182
- out.push(...indentContinuation(`_Suggestion:_ ${finding.suggestion}`));
231
+ out.push(...indentContinuation(`_Suggestion:_ ${stripStateMarkers(finding.suggestion)}`));
183
232
  }
184
233
  // Separator so a rationale ending in `</details>` cannot swallow the next
185
234
  // bullet. Findings are already loose list items, so this changes no spacing.
186
235
  out.push("");
187
236
  return out;
188
237
  }
238
+ // @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
239
+ /**
240
+ * The visible one-line audit note in the OPEN body, naming the addressing PRs. This
241
+ * is what keeps requalification from being a "collapsed fold nobody reads": the
242
+ * count and PR numbers show above the fold. Empty when nothing was requalified.
243
+ */
244
+ function requalificationAuditNote(requalified) {
245
+ if (requalified.length === 0) {
246
+ return [];
247
+ }
248
+ const prNumbers = [
249
+ ...new Set(requalified
250
+ .map((finding) => finding.requalifiedBy?.prNumber)
251
+ .filter((n) => n != null)),
252
+ ].sort((a, b) => a - b);
253
+ const prList = prNumbers.map((n) => `#${n}`).join(", ");
254
+ return [
255
+ `> 🔁 **${requalified.length} finding(s)** marked addressed by stacked PR(s) (${prList}); ` +
256
+ "excluded from the decision but shown below.",
257
+ "",
258
+ ];
259
+ }
260
+ /** One bullet per finding in the "Addressed in stacked PRs" section — names the
261
+ * addressing PR and the exact upstack path relied on. */
262
+ function addressedLines(finding, fp, link) {
263
+ const requalified = finding.requalifiedBy;
264
+ const reason = requalified.reason ? `: ${stripStateMarkers(requalified.reason)}` : "";
265
+ return [
266
+ `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} \`id:${fp}\` — addressed in ` +
267
+ `#${requalified.prNumber} (\`${stripStateMarkers(requalified.file)}\`)${reason}`,
268
+ ];
269
+ }
270
+ /**
271
+ * Index the feedback records that answer a finding actually present in this
272
+ * comment, keyed by the id the comment renders. A record whose finding is gone
273
+ * (the flagged code changed, so the fingerprint moved) is dropped: it can no
274
+ * longer be shown, counted, or audited, so carrying it forward only grows state.
275
+ */
276
+ function matchedFeedback(feedback, ids) {
277
+ const matched = new Map();
278
+ for (const record of feedback) {
279
+ if (ids.has(record.fp) && !matched.has(record.fp)) {
280
+ matched.set(record.fp, record);
281
+ }
282
+ }
283
+ return matched;
284
+ }
285
+ // @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
286
+ /** The visible one-line audit note for author responses. Empty when no reply
287
+ * matched a finding in this comment. */
288
+ function feedbackAuditNote(records) {
289
+ if (records.length === 0) {
290
+ return [];
291
+ }
292
+ const applied = records.filter((record) => record.applied).length;
293
+ return [
294
+ `> 💬 **${records.length} finding(s)** have an author response (${applied} applied).`,
295
+ "",
296
+ ];
297
+ }
298
+ /** ` · 💬 [@login replied](url)` on an active finding — the entire visible trace
299
+ * of a reply. The reply's own text is never part of it. */
300
+ function replyAnnotation(record) {
301
+ return record ? ` · 💬 ${replyLink(`${replyAuthor(record)} replied`, record)}` : "";
302
+ }
303
+ /** The audit tail of a "Dismissed" bullet: an explicit `/dismiss`, or the reply
304
+ * that cleared the finding. */
305
+ function droppedSuffix(dismissal, reply) {
306
+ if (dismissal) {
307
+ const who = dismissal.by ? ` by @${stripStateMarkers(dismissal.by)}` : "";
308
+ const why = dismissal.reason ? ` — ${stripStateMarkers(dismissal.reason)}` : "";
309
+ return `${who}${why}`;
310
+ }
311
+ return reply ? ` — dismissed via reply by ${replyLink(replyAuthor(reply), reply)}` : "";
312
+ }
313
+ // @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
314
+ /** Attach the matched feedback to the state blob. It rides the embedded state
315
+ * like dismissals do, so the next run re-reads what was recorded (including a
316
+ * verdict already decided) instead of re-deriving it.
317
+ *
318
+ * `pins` is written whole and unfiltered — NOT indexed by the findings or the records
319
+ * this render happens to show. A pin is a maintainer's decision about a finding, so it
320
+ * must outlive a run where the reply was edited away, the record was dropped, or the
321
+ * finding itself was not re-emitted. */
322
+ function reviewState(state, feedbackByFp, pins = []) {
323
+ const feedback = [...feedbackByFp.values()];
324
+ const withFeedback = feedback.length > 0 ? { ...state, feedback } : state;
325
+ return pins.length > 0 ? { ...withFeedback, pins } : withFeedback;
326
+ }
189
327
  /** Parse the fingerprints embedded in a previously-posted comment body. */
190
328
  export function parseEmbeddedFingerprints(body, tag) {
191
329
  // Escape the (config-controlled) tag so regex metacharacters can't break the match.
@@ -213,6 +351,7 @@ export function worstDecision(decisions) {
213
351
  }
214
352
  /** GitHub's comment body limit is ~65k chars; keep a margin. */
215
353
  const MAX_COMMENT_CHARS = 60_000;
354
+ // @ref LLP 0005#truncation-and-aggregate-state [constrained-by] — truncation trims shown findings only; dismissed findings always kept in state
216
355
  /**
217
356
  * One aggregated comment under the single existing marker: a scope summary table,
218
357
  * an optional coverage block, one <details> per scope (findings rendered with
@@ -221,19 +360,33 @@ const MAX_COMMENT_CHARS = 60_000;
221
360
  * carries the real per-scope data. Oversized bodies trim each scope's findings to
222
361
  * the most severe N (halving until it fits, floor 3) with a per-scope note.
223
362
  */
224
- export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
363
+ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, feedback = [], pins = []) {
225
364
  const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
226
365
  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).
366
+ // Feedback is keyed by the SAME scope-namespaced id the comment renders, so a
367
+ // record can never cross scopes.
368
+ const feedbackById = matchedFeedback(feedback, new Set(results.flatMap((result) => result.review.findings.map((f) => idOf(result, f)))));
369
+ // Split each scope's findings into kept/requalified/dropped once (dismissal and
370
+ // requalification are both limit-independent). `kept` is the active/blocking set;
371
+ // requalified findings are addressed by a stacked PR — counted and shown, never
372
+ // in the blocking list.
228
373
  const perScope = results.map((result) => {
229
374
  const withId = result.review.findings.map((finding) => ({
230
375
  finding,
231
376
  id: idOf(result, finding),
232
377
  }));
378
+ // An applied reply drops a finding out of the active list, like a dismissal.
379
+ const isDropped = (entry) => dismissedByFp.has(entry.id) || feedbackById.get(entry.id)?.applied === true;
380
+ const notDismissed = withId.filter((entry) => !isDropped(entry));
233
381
  return {
234
382
  result,
235
- kept: withId.filter((entry) => !dismissedByFp.has(entry.id)),
236
- dropped: withId.filter((entry) => dismissedByFp.has(entry.id)),
383
+ kept: notDismissed.filter((entry) => !entry.finding.requalifiedBy),
384
+ requalified: notDismissed.filter((entry) => entry.finding.requalifiedBy),
385
+ dropped: withId.filter((entry) => isDropped(entry)),
386
+ // The scope's own author responses, for its audit note.
387
+ feedback: withId
388
+ .map((entry) => feedbackById.get(entry.id))
389
+ .filter((record) => record != null),
237
390
  };
238
391
  });
239
392
  const worst = worstDecision(results.map((result) => result.review.decision));
@@ -249,7 +402,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
249
402
  "| --- | --- | --- |",
250
403
  ];
251
404
  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} |`);
405
+ lines.push(`| ${stripStateMarkers(result.scope)} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
253
406
  }
254
407
  lines.push("");
255
408
  const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
@@ -258,14 +411,14 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
258
411
  if (unmatched.length > 0) {
259
412
  const shown = unmatched
260
413
  .slice(0, 10)
261
- .map((file) => `\`${file}\``)
414
+ .map((file) => `\`${stripStateMarkers(file)}\``)
262
415
  .join(", ");
263
416
  const more = unmatched.length > 10 ? `, …(+${unmatched.length - 10} more)` : "";
264
417
  lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
265
418
  }
266
419
  for (const result of results) {
267
420
  for (const note of result.review.incomplete) {
268
- lines.push(`> - [${result.scope}] ${note}`);
421
+ lines.push(`> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`);
269
422
  }
270
423
  }
271
424
  lines.push("");
@@ -274,38 +427,57 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
274
427
  // embedded state trims KEPT findings to the same set so a truncated comment
275
428
  // still fits GitHub's body limit (the hidden findings are noted, not silently
276
429
  // carried) — but dismissed findings are always kept in state (see below).
277
- const rendered = perScope.map(({ result, kept, dropped }) => ({
430
+ const rendered = perScope.map(({ result, kept, requalified, dropped, feedback: replies }) => ({
278
431
  result,
432
+ replies,
279
433
  shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
280
434
  hidden: Math.max(0, kept.length - limitPerScope),
435
+ // The requalified section is trimmed by the same per-scope limit as shown: it
436
+ // is coordinator-populated (a wide stack can requalify many findings at once),
437
+ // and an untrimmed section would keep the truncation loop below from ever
438
+ // converging under MAX_COMMENT_CHARS. The audit note carries the TOTAL count,
439
+ // so trimming never hides that requalification happened.
440
+ requalified: requalified.slice(0, limitPerScope),
441
+ requalifiedHidden: Math.max(0, requalified.length - limitPerScope),
442
+ requalifiedAll: requalified,
281
443
  dropped,
282
444
  }));
283
- for (const { result, shown, hidden } of rendered) {
284
- const open = shown.length > 0 ? " open" : "";
445
+ for (const { result, replies, shown, hidden, requalified, requalifiedAll, requalifiedHidden, } of rendered) {
446
+ // @ref LLP 0011#suppression-is-never-silent a reply-suppressed scope opens
447
+ // its fold so the audit note is visible, matching renderMarkdown and LLP 0010's
448
+ // visible-suppression rule (else a scope replies cleared reads as clean).
449
+ const open = shown.length > 0 || requalifiedAll.length > 0 || replies.length > 0 ? " open" : "";
285
450
  const keptCount = shown.length + hidden;
286
- lines.push(`<details${open}>`, `<summary>${result.scope} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
451
+ lines.push(`<details${open}>`, `<summary>${stripStateMarkers(result.scope)} — ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
287
452
  if (result.review.summary) {
288
- lines.push(result.review.summary, "");
453
+ lines.push(stripStateMarkers(result.review.summary), "");
289
454
  }
455
+ lines.push(...requalificationAuditNote(requalifiedAll.map((entry) => entry.finding)));
456
+ lines.push(...feedbackAuditNote(replies));
290
457
  if (shown.length === 0) {
291
458
  lines.push("No findings.", "");
292
459
  }
293
460
  else {
294
- lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
461
+ lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding), feedbackById));
295
462
  }
296
463
  if (hidden > 0) {
297
464
  lines.push(`_…and ${hidden} more finding(s) — see the workflow log._`, "");
298
465
  }
466
+ if (requalifiedAll.length > 0) {
467
+ lines.push(`**🔁 Addressed in stacked PRs (${requalifiedAll.length})**`, "", ...requalified.flatMap((entry) => addressedLines(entry.finding, entry.id, link)));
468
+ if (requalifiedHidden > 0) {
469
+ lines.push(`_…and ${requalifiedHidden} more addressed finding(s) — see the workflow log._`);
470
+ }
471
+ lines.push("");
472
+ }
299
473
  lines.push("</details>", "");
300
474
  }
301
475
  const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
302
476
  if (allDropped.length > 0) {
303
477
  lines.push("<details>", `<summary>🚫 Dismissed on this PR (${allDropped.length})</summary>`, "");
304
478
  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}`);
479
+ const suffix = droppedSuffix(dismissedByFp.get(id), feedbackById.get(id));
480
+ lines.push(`- **${stripStateMarkers(finding.title)}** ${location(finding, link)} \`id:${id}\` _(${stripStateMarkers(scope)})_${suffix}`);
309
481
  }
310
482
  lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
311
483
  }
@@ -316,11 +488,30 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
316
488
  // review) so /undismiss can restore them and the Dismissed section persists
317
489
  // across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
318
490
  // 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
- }));
491
+ const stateScopes = rendered.map(({ result, shown, requalified, dropped }) => {
492
+ // @ref LLP 0011#suppression-is-never-silent — strip any per-scope `feedback`
493
+ // a freshly-reviewed scope's ReviewRunResult carries: the top-level feedback
494
+ // array (reviewState below) is the single source of truth. A stale per-scope
495
+ // copy that survived here would be read back on a later carried-over run and
496
+ // could re-apply a record a human /undismiss already overrode at the top level.
497
+ const { feedback: _feedback, ...review } = result.review;
498
+ return {
499
+ scope: result.scope,
500
+ isDefault: result.isDefault,
501
+ // Requalified findings ride the embedded state (like dismissed ones) so a
502
+ // re-render (/dismiss) round-trips them and the addressed section persists.
503
+ // Under truncation they are trimmed exactly like `shown` — state bytes count
504
+ // toward the comment size, so an untrimmed list would defeat the cap loop.
505
+ review: {
506
+ ...review,
507
+ findings: [
508
+ ...shown,
509
+ ...requalified.map((entry) => entry.finding),
510
+ ...dropped.map((entry) => entry.finding),
511
+ ],
512
+ },
513
+ };
514
+ });
324
515
  const merged = {
325
516
  decision: worst,
326
517
  findings: stateScopes.flatMap((scope) => scope.review.findings),
@@ -331,12 +522,17 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
331
522
  };
332
523
  const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
333
524
  lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
334
- lines.push(`<!-- ${tag}:state=${encodeState({ review: merged, dismissed, scopes: stateScopes })} -->`);
525
+ // Feedback records and `/undismiss` pins ride the state whole, never trimmed by the
526
+ // cap loop: each is a handful of bytes, and losing one would lose a verdict already
527
+ // decided — or a maintainer's restore, which no later run could recover.
528
+ lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review: merged, dismissed, scopes: stateScopes }, feedbackById, pins))} -->`);
335
529
  return lines.join("\n");
336
530
  };
337
531
  let limit = Number.POSITIVE_INFINITY;
338
532
  let body = buildBody(limit);
339
- const largestScope = Math.max(0, ...perScope.map((entry) => entry.kept.length));
533
+ // Seed from the largest per-scope section the limit applies to — kept OR
534
+ // requalified — so the halving loop shrinks whichever one is oversized.
535
+ const largestScope = Math.max(0, ...perScope.map((entry) => Math.max(entry.kept.length, entry.requalified.length)));
340
536
  while (body.length > MAX_COMMENT_CHARS && limit > 3) {
341
537
  limit =
342
538
  limit === Number.POSITIVE_INFINITY
@@ -359,7 +555,21 @@ export function parseReviewState(body, tag) {
359
555
  try {
360
556
  const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
361
557
  if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
362
- return parsed;
558
+ // The v3 `feedback` field is shape-validated rather than trusted: it feeds
559
+ // the blocking decision, so a malformed blob must yield no records, not
560
+ // junk ones. Same for the v4 `pins`.
561
+ const feedback = FeedbackRecordSchema.array().safeParse(parsed.feedback ?? []);
562
+ const records = feedback.success ? feedback.data : [];
563
+ const parsedPins = FeedbackPinSchema.array().safeParse(parsed.pins ?? []);
564
+ // Migration on read: a v3 comment stored its pins on the records themselves, so
565
+ // collectPins lifts those into the set. Without this, the first render by this
566
+ // version would write a state with no pins at all and silently release every
567
+ // `/undismiss` a maintainer had already made.
568
+ return {
569
+ ...parsed,
570
+ feedback: records,
571
+ pins: collectPins(parsedPins.success ? parsedPins.data : [], records),
572
+ };
363
573
  }
364
574
  }
365
575
  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
+ }