@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.
- package/README.md +118 -13
- package/build/cli.js +7 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- 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 +3 -0
- 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 +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- 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 +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +255 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +290 -15
- package/build/core/schema.js +213 -2
- package/build/core/scrub.js +4 -0
- 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 +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- 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 +50 -1
- 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 +96 -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,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
|
-
|
|
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
|
-
//
|
|
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:
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|