@expo/code-review-cli 0.6.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.
- package/README.md +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
package/build/core/render.js
CHANGED
|
@@ -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, "<!--");
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
125
|
-
|
|
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,22 +198,132 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
|
151
198
|
}
|
|
152
199
|
out.push(`### ${severityHeading(severity)} (${group.length})`, "");
|
|
153
200
|
for (const finding of group) {
|
|
154
|
-
|
|
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
|
}
|
|
160
|
-
|
|
208
|
+
// @ref LLP 0005#comment-rendering [constrained-by] — blank lines must stay truly empty or <details> escapes the list (euxy#45)
|
|
209
|
+
/**
|
|
210
|
+
* Indent every line of a multi-line value to a list item's content column.
|
|
211
|
+
*
|
|
212
|
+
* Rationales embed a `<details>` block, and only indenting the first line let
|
|
213
|
+
* that HTML escape the list item: GitHub then treated the closing `</details>`
|
|
214
|
+
* as ending a top-level HTML block, and because the next finding's bullet
|
|
215
|
+
* followed after a single newline it was emitted as raw text instead of
|
|
216
|
+
* Markdown. Every finding after the first in a group rendered with visible
|
|
217
|
+
* `**` and backticks.
|
|
218
|
+
*
|
|
219
|
+
* Blank lines stay truly empty — trailing whitespace would make them
|
|
220
|
+
* non-blank and reopen the same class of parsing bug.
|
|
221
|
+
*/
|
|
222
|
+
function indentContinuation(value, indent = " ") {
|
|
223
|
+
return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
|
|
224
|
+
}
|
|
225
|
+
function renderFindingLines(finding, link, id = fingerprintFinding(finding), reply) {
|
|
161
226
|
const out = [
|
|
162
|
-
`- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}
|
|
163
|
-
|
|
227
|
+
`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
|
|
228
|
+
...indentContinuation(stripStateMarkers(finding.rationale)),
|
|
164
229
|
];
|
|
165
230
|
if (finding.suggestion) {
|
|
166
|
-
out.push(`
|
|
231
|
+
out.push(...indentContinuation(`_Suggestion:_ ${stripStateMarkers(finding.suggestion)}`));
|
|
167
232
|
}
|
|
233
|
+
// Separator so a rationale ending in `</details>` cannot swallow the next
|
|
234
|
+
// bullet. Findings are already loose list items, so this changes no spacing.
|
|
235
|
+
out.push("");
|
|
168
236
|
return out;
|
|
169
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
|
+
}
|
|
170
327
|
/** Parse the fingerprints embedded in a previously-posted comment body. */
|
|
171
328
|
export function parseEmbeddedFingerprints(body, tag) {
|
|
172
329
|
// Escape the (config-controlled) tag so regex metacharacters can't break the match.
|
|
@@ -194,6 +351,7 @@ export function worstDecision(decisions) {
|
|
|
194
351
|
}
|
|
195
352
|
/** GitHub's comment body limit is ~65k chars; keep a margin. */
|
|
196
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
|
|
197
355
|
/**
|
|
198
356
|
* One aggregated comment under the single existing marker: a scope summary table,
|
|
199
357
|
* an optional coverage block, one <details> per scope (findings rendered with
|
|
@@ -202,19 +360,33 @@ const MAX_COMMENT_CHARS = 60_000;
|
|
|
202
360
|
* carries the real per-scope data. Oversized bodies trim each scope's findings to
|
|
203
361
|
* the most severe N (halving until it fits, floor 3) with a per-scope note.
|
|
204
362
|
*/
|
|
205
|
-
export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
363
|
+
export function renderAggregateMarkdown(results, tag, dismissed, link, opts, feedback = [], pins = []) {
|
|
206
364
|
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
207
365
|
const idOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
|
|
208
|
-
//
|
|
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.
|
|
209
373
|
const perScope = results.map((result) => {
|
|
210
374
|
const withId = result.review.findings.map((finding) => ({
|
|
211
375
|
finding,
|
|
212
376
|
id: idOf(result, finding),
|
|
213
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));
|
|
214
381
|
return {
|
|
215
382
|
result,
|
|
216
|
-
kept:
|
|
217
|
-
|
|
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),
|
|
218
390
|
};
|
|
219
391
|
});
|
|
220
392
|
const worst = worstDecision(results.map((result) => result.review.decision));
|
|
@@ -230,7 +402,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
230
402
|
"| --- | --- | --- |",
|
|
231
403
|
];
|
|
232
404
|
for (const { result, kept } of perScope) {
|
|
233
|
-
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} |`);
|
|
234
406
|
}
|
|
235
407
|
lines.push("");
|
|
236
408
|
const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
|
|
@@ -239,14 +411,14 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
239
411
|
if (unmatched.length > 0) {
|
|
240
412
|
const shown = unmatched
|
|
241
413
|
.slice(0, 10)
|
|
242
|
-
.map((file) => `\`${file}\``)
|
|
414
|
+
.map((file) => `\`${stripStateMarkers(file)}\``)
|
|
243
415
|
.join(", ");
|
|
244
416
|
const more = unmatched.length > 10 ? `, …(+${unmatched.length - 10} more)` : "";
|
|
245
417
|
lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
|
|
246
418
|
}
|
|
247
419
|
for (const result of results) {
|
|
248
420
|
for (const note of result.review.incomplete) {
|
|
249
|
-
lines.push(`> - [${result.scope}] ${note}`);
|
|
421
|
+
lines.push(`> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`);
|
|
250
422
|
}
|
|
251
423
|
}
|
|
252
424
|
lines.push("");
|
|
@@ -255,38 +427,57 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
255
427
|
// embedded state trims KEPT findings to the same set so a truncated comment
|
|
256
428
|
// still fits GitHub's body limit (the hidden findings are noted, not silently
|
|
257
429
|
// carried) — but dismissed findings are always kept in state (see below).
|
|
258
|
-
const rendered = perScope.map(({ result, kept, dropped }) => ({
|
|
430
|
+
const rendered = perScope.map(({ result, kept, requalified, dropped, feedback: replies }) => ({
|
|
259
431
|
result,
|
|
432
|
+
replies,
|
|
260
433
|
shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
|
|
261
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,
|
|
262
443
|
dropped,
|
|
263
444
|
}));
|
|
264
|
-
for (const { result, shown, hidden } of rendered) {
|
|
265
|
-
|
|
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" : "";
|
|
266
450
|
const keptCount = shown.length + hidden;
|
|
267
|
-
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>`, "");
|
|
268
452
|
if (result.review.summary) {
|
|
269
|
-
lines.push(result.review.summary, "");
|
|
453
|
+
lines.push(stripStateMarkers(result.review.summary), "");
|
|
270
454
|
}
|
|
455
|
+
lines.push(...requalificationAuditNote(requalifiedAll.map((entry) => entry.finding)));
|
|
456
|
+
lines.push(...feedbackAuditNote(replies));
|
|
271
457
|
if (shown.length === 0) {
|
|
272
458
|
lines.push("No findings.", "");
|
|
273
459
|
}
|
|
274
460
|
else {
|
|
275
|
-
lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
|
|
461
|
+
lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding), feedbackById));
|
|
276
462
|
}
|
|
277
463
|
if (hidden > 0) {
|
|
278
464
|
lines.push(`_…and ${hidden} more finding(s) — see the workflow log._`, "");
|
|
279
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
|
+
}
|
|
280
473
|
lines.push("</details>", "");
|
|
281
474
|
}
|
|
282
475
|
const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
|
|
283
476
|
if (allDropped.length > 0) {
|
|
284
477
|
lines.push("<details>", `<summary>🚫 Dismissed on this PR (${allDropped.length})</summary>`, "");
|
|
285
478
|
for (const { finding, id, scope } of allDropped) {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const why = record.reason ? ` — ${record.reason}` : "";
|
|
289
|
-
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}`);
|
|
290
481
|
}
|
|
291
482
|
lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
|
|
292
483
|
}
|
|
@@ -297,11 +488,30 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
297
488
|
// review) so /undismiss can restore them and the Dismissed section persists
|
|
298
489
|
// across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
|
|
299
490
|
// keep both v2 and v1 consumers working.
|
|
300
|
-
const stateScopes = rendered.map(({ result, shown, dropped }) =>
|
|
301
|
-
scope
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
});
|
|
305
515
|
const merged = {
|
|
306
516
|
decision: worst,
|
|
307
517
|
findings: stateScopes.flatMap((scope) => scope.review.findings),
|
|
@@ -312,12 +522,17 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
|
312
522
|
};
|
|
313
523
|
const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
|
|
314
524
|
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
315
|
-
|
|
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))} -->`);
|
|
316
529
|
return lines.join("\n");
|
|
317
530
|
};
|
|
318
531
|
let limit = Number.POSITIVE_INFINITY;
|
|
319
532
|
let body = buildBody(limit);
|
|
320
|
-
|
|
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)));
|
|
321
536
|
while (body.length > MAX_COMMENT_CHARS && limit > 3) {
|
|
322
537
|
limit =
|
|
323
538
|
limit === Number.POSITIVE_INFINITY
|
|
@@ -340,7 +555,21 @@ export function parseReviewState(body, tag) {
|
|
|
340
555
|
try {
|
|
341
556
|
const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
|
|
342
557
|
if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
|
|
343
|
-
|
|
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
|
+
};
|
|
344
573
|
}
|
|
345
574
|
}
|
|
346
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
|
+
}
|