@expo/code-review-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -1,12 +1,195 @@
1
+ // @ref LLP 0008#github-reporter-identity — the reporter's core problem: which PR comment is ours; identity is proven by author + marker, never marker alone
1
2
  import { writeFile, mkdtemp, rm } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { resolveTrustedTool, run } from "../core/exec.js";
5
6
  import { parseUnifiedDiff } from "../core/diff.js";
6
7
  import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
7
- import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
+ import { matchReplies } from "../core/responses.js";
9
+ import { dropStaleVerdict, feedbackApplied, feedbackNeedsPrAuthor } from "../core/adjudicate.js";
10
+ import { applyPins, collectPins, fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
11
  import { appendStepSummary } from "../core/step-summary.js";
9
12
  const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
13
+ /**
14
+ * PR-author lookups shared across reporter INSTANCES, keyed by the exact PR they
15
+ * describe. Routed CI builds one GitHubReporter per scope comment (plus one for the
16
+ * aggregate comment), and every one of them would otherwise run its own `gh pr view` for
17
+ * the SAME PR. The key carries the checkout, the repo and the PR number, so a lookup can
18
+ * never leak across repos or PRs in one process (the `ecr feedback` crawl walks many
19
+ * PRs). The in-flight promise is stored, not just the result, so concurrent scopes share
20
+ * one call.
21
+ */
22
+ const prAuthorByPr = new Map();
23
+ /** Cache key for prAuthorByPr. Newline-joined with the free-form part (the checkout
24
+ * path) LAST: a PR number is digits and a repo is `owner/name`, neither of which can
25
+ * contain a newline, so two different PRs can never collide on one key. Exported for
26
+ * tests. */
27
+ export function prAuthorCacheKey(repo, prNumber, cwd) {
28
+ return `${prNumber}\n${repo}\n${cwd ?? ""}`;
29
+ }
30
+ /**
31
+ * Run `resolve` at most once per PR for the lifetime of the process — but cache only a
32
+ * SUCCESSFUL answer. A `gh pr view` that fails (rate limit, network blip) resolves to
33
+ * null, and caching that null would make one transient error fail-close every later
34
+ * scope of the same run: routed CI builds a reporter per scope, all reading this map,
35
+ * so every reply would be marked `author: false` and the adjudicated clear path could
36
+ * not fire again for the whole process. The entry is therefore removed once the promise
37
+ * settles to null (or rejects, which leaves nothing dangling either), so the next scope
38
+ * retries. Concurrency is unchanged: the in-flight promise is what's stored, so
39
+ * simultaneous scopes still join one call rather than each firing their own. Exported
40
+ * for tests (the reporter's own call path needs `gh`).
41
+ */
42
+ export function sharedPrAuthor(key, resolve) {
43
+ const cached = prAuthorByPr.get(key);
44
+ if (cached) {
45
+ return cached;
46
+ }
47
+ // Only ever forget OUR entry: a later call may already have started a new lookup.
48
+ const forget = (settled) => {
49
+ if (prAuthorByPr.get(key) === settled) {
50
+ prAuthorByPr.delete(key);
51
+ }
52
+ };
53
+ const pending = resolve().then((login) => {
54
+ if (login == null) {
55
+ forget(pending);
56
+ }
57
+ return login;
58
+ }, (error) => {
59
+ forget(pending);
60
+ throw error;
61
+ });
62
+ prAuthorByPr.set(key, pending);
63
+ return pending;
64
+ }
65
+ // @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged
66
+ /**
67
+ * Carry a decision already recorded in the prior comment state onto the freshly
68
+ * matched records. A **verdict** (and the `applied` it justified) is bound to the words
69
+ * it judged AND to the source it judged: it carries only while the newest reply is the
70
+ * SAME comment it was decided about and `dropStaleVerdict` agrees the head is
71
+ * unchanged. A newer reply answers different words; a newer head means the code the
72
+ * rebuttal relied on may be gone. Either way the reply is re-judged rather than trusted.
73
+ *
74
+ * Fresh records own the reply identity (author, comment id, link); the prior state owns
75
+ * the decision. A maintainer's `/undismiss` pin is NOT merged here — it belongs to the
76
+ * finding, lives in the comment state's own `pins` set, and is stamped back on by
77
+ * `applyPins` (which is what keeps it alive through a run that matches no reply at all).
78
+ */
79
+ function mergeFeedback(fresh, previous, headSha) {
80
+ const priorByFp = new Map(previous.map((record) => [record.fp, record]));
81
+ return fresh.map((record) => {
82
+ const prior = priorByFp.get(record.fp);
83
+ if (!prior || prior.commentId !== record.commentId) {
84
+ // No prior decision, or different words: nothing decided about the old comment
85
+ // carries.
86
+ return record;
87
+ }
88
+ return dropStaleVerdict({
89
+ ...record,
90
+ ...(prior.verdict !== undefined ? { verdict: prior.verdict } : {}),
91
+ ...(prior.reason !== undefined ? { reason: prior.reason } : {}),
92
+ ...(prior.sourceSha !== undefined ? { sourceSha: prior.sourceSha } : {}),
93
+ applied: prior.applied,
94
+ }, headSha);
95
+ });
96
+ }
97
+ // @ref LLP 0011#deterministic-matching [implements] — the pure core of matchAdjudicationItems: no IO, so it is unit-testable; the caller supplies the fetched state/replies
98
+ /**
99
+ * Pair each matched reply with the finding it answers and its raw reply text, carrying
100
+ * any prior verdict forward. Pure: the caller has already fetched `previousFeedback`
101
+ * (the comment's stored records) and `replies`. `fpOf` MUST key findings the same way
102
+ * that comment stores them (scope-namespaced for an aggregate comment, plain otherwise)
103
+ * or a prior verdict can never carry — its fp would not match the fresh record's.
104
+ * `headSha` is the source revision being reviewed: a stored verdict carries only when
105
+ * it judged that same revision (see mergeFeedback). `pins` is the comment's
106
+ * `/undismiss` set: it is stamped onto the matched records so a pinned finding is never
107
+ * re-judged and never re-cleared (a v3 comment's record-level flags migrate in through
108
+ * collectPins). Exported for tests.
109
+ */
110
+ export function buildAdjudicationItems(review, previousFeedback, replies, fpOf, match, headSha, pins) {
111
+ const withFp = review.findings.map((finding) => ({ finding, fp: fpOf(finding) }));
112
+ const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match }), previousFeedback, headSha), collectPins(pins, previousFeedback));
113
+ const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
114
+ const bodyById = new Map(replies.map((reply) => [reply.id, reply.body]));
115
+ const items = [];
116
+ for (const record of records) {
117
+ const finding = findingByFp.get(record.fp);
118
+ if (!finding) {
119
+ continue;
120
+ }
121
+ items.push({ finding, record, replyText: bodyById.get(record.commentId) ?? "" });
122
+ }
123
+ return items;
124
+ }
125
+ /**
126
+ * The findings a posted comment holds, keyed by the id that comment renders them under:
127
+ * scope-namespaced on an aggregate (comment:'single') comment, plain otherwise. This is
128
+ * both the valid-id set a `/dismiss` is checked against and the lookup that re-derives
129
+ * each feedback record's `applied` under the current config.
130
+ */
131
+ function stateFindingsById(state) {
132
+ const scopes = state.scopes;
133
+ if (scopes && scopes.length > 0) {
134
+ return new Map(scopes.flatMap((scope) => scope.review.findings.map((finding) => [scopedFingerprint(scope.isDefault ? null : scope.scope, finding), finding])));
135
+ }
136
+ return new Map(state.review.findings.map((finding) => [fingerprintFinding(finding), finding]));
137
+ }
138
+ // @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — `/undismiss` writes the pin into the state's own pin set (never only onto a reply record), and `/dismiss` is the maintainer action that lifts it
139
+ // @ref LLP 0011#hard-floors-in-code [implements] — a re-render is a render: every kept record's `applied` is re-derived from feedbackApplied under the CURRENT config, never carried over as a stored fact
140
+ /**
141
+ * The pure state transition behind applyDismissal: which findings end up dismissed,
142
+ * which are pinned back to the active list, and what each feedback record's `applied`
143
+ * flag is under the config in force NOW. Exported for tests (the reporter's own path
144
+ * needs `gh`).
145
+ *
146
+ * Three rules meet here:
147
+ * - a `/dismiss` (an fp in `add`) is only recorded for an id this comment actually
148
+ * holds, and it LIFTS any pin on that finding — the same trusted hand deciding the
149
+ * opposite way;
150
+ * - a `/undismiss` (an fp in `remove`) restores a finding a REPLY cleared, not just a
151
+ * manual dismissal: it pins the finding so a later re-review recomputing `applied`
152
+ * from the still-present reply keeps it active;
153
+ * - every record's `applied` is then re-derived with `feedbackApplied` — the same single
154
+ * decision function every other render path uses. Passing a record through untouched
155
+ * would keep honoring the config of the run that stored it, so a repo that has since
156
+ * tightened `dismiss` (or widened `protectedCategories`) would leave an unrelated
157
+ * finding hidden until the next full review. A record whose finding is no longer in
158
+ * the comment keeps its stored flag, exactly as computeFeedback does.
159
+ */
160
+ export function applyDismissalToState(state, add, remove, config, by, reason) {
161
+ const findingById = stateFindingsById(state);
162
+ const matched = add.filter((fp) => findingById.has(fp));
163
+ const unmatched = add.filter((fp) => !findingById.has(fp));
164
+ const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
165
+ for (const fp of matched) {
166
+ if (!dismissed.some((record) => record.fp === fp)) {
167
+ dismissed.push({ fp, by, reason });
168
+ }
169
+ }
170
+ const records = state.feedback ?? [];
171
+ const removeSet = new Set(remove);
172
+ const addSet = new Set(matched);
173
+ const pins = collectPins(state.pins, records).filter((pin) => !addSet.has(pin.fp));
174
+ for (const record of records) {
175
+ if (removeSet.has(record.fp) && !pins.some((pin) => pin.fp === record.fp)) {
176
+ // Pinned against the reply that is current right now: that same reply must not
177
+ // lift the pin later, only a maintainer reply posted after it (see applyPins).
178
+ pins.push({ fp: record.fp, commentId: record.commentId });
179
+ }
180
+ }
181
+ const stamped = applyPins(records, pins);
182
+ const feedback = stamped.records.map((record) => {
183
+ const finding = findingById.get(record.fp);
184
+ // No feedback config ⇒ the feature is off for this caller, so nothing is applied;
185
+ // the record itself (who replied, any verdict) is still preserved.
186
+ if (!config) {
187
+ return { ...record, applied: false };
188
+ }
189
+ return finding ? { ...record, applied: feedbackApplied(finding, record, config) } : record;
190
+ });
191
+ return { dismissed, feedback, pins: stamped.pins, matched, unmatched };
192
+ }
10
193
  /**
11
194
  * The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
12
195
  * by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
@@ -19,6 +202,7 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
19
202
  * cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
20
203
  * tests.
21
204
  */
205
+ // @ref LLP 0008#github-reporter-identity [constrained-by] — marker-only matching would let a forged comment (from the untrusted PR author) carry forged dismissal state forward; author+marker is what makes identity unspoofable
22
206
  export function selectOwnComments(comments, marker, ownLogin) {
23
207
  if (!ownLogin) {
24
208
  return [];
@@ -49,6 +233,9 @@ export class GitHubReporter {
49
233
  * stable for the process, and every reporter method consults it.
50
234
  */
51
235
  resolveOwnLogin() {
236
+ if (this.options.ownLogin) {
237
+ return Promise.resolve(this.options.ownLogin);
238
+ }
52
239
  this.ownLoginResolution ??= (async () => {
53
240
  try {
54
241
  const gh = await resolveTrustedTool("gh");
@@ -67,6 +254,41 @@ export class GitHubReporter {
67
254
  })();
68
255
  return this.ownLoginResolution;
69
256
  }
257
+ /**
258
+ * The PR author's login, used to mark a reply as coming from the author (see
259
+ * replyComments) so the adjudicated clear path can gate on it. Resolved via
260
+ * `gh pr view --json author` and fail-soft — a null result marks no reply as the
261
+ * author's, so the adjudicated path clears nothing (fail closed), exactly like an
262
+ * unresolved own-login. Memoized per PR across reporter INSTANCES (see
263
+ * prAuthorByPr), not per instance: routed CI builds one reporter per scope for the
264
+ * same PR, and the author's login is a property of the PR, not of the reporter.
265
+ */
266
+ resolvePrAuthor() {
267
+ const key = prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd);
268
+ return sharedPrAuthor(key, async () => {
269
+ try {
270
+ const gh = await resolveTrustedTool("gh");
271
+ const { stdout } = await run(gh, [
272
+ "pr",
273
+ "view",
274
+ String(this.options.prNumber),
275
+ "--repo",
276
+ this.options.repo,
277
+ "--json",
278
+ "author",
279
+ "--jq",
280
+ ".author.login",
281
+ ], { cwd: this.options.cwd });
282
+ const login = stdout.trim();
283
+ return login || null;
284
+ }
285
+ catch {
286
+ // No PR author resolvable (missing PR, API error): fail closed — no reply is
287
+ // treated as the author's, so nothing clears via the adjudicated path.
288
+ return null;
289
+ }
290
+ });
291
+ }
70
292
  /** This reporter's own marker comments, author-verified (see selectOwnComments). */
71
293
  async ownComments() {
72
294
  const [comments, ownLogin] = await Promise.all([
@@ -75,6 +297,7 @@ export class GitHubReporter {
75
297
  ]);
76
298
  return selectOwnComments(comments, this.marker, ownLogin);
77
299
  }
300
+ // @ref LLP 0008#comment-lifecycle [constrained-by] — gates on author_association (OWNER/MEMBER/COLLABORATOR), not on posting the marker string, so only a maintainer can skip review
78
301
  async checkBreakGlass() {
79
302
  const comments = await this.fetchAllComments();
80
303
  return comments.some((comment) => typeof comment.body === "string" &&
@@ -84,26 +307,208 @@ export class GitHubReporter {
84
307
  async postSkipNote() {
85
308
  await this.upsertComment(`${this.marker}\n🤖 AI review skipped via \`${this.options.breakGlassMarker}\`.`);
86
309
  }
87
- async report(review) {
310
+ /**
311
+ * Post/update the single-scope comment. When `feedback` is supplied (the
312
+ * adjudication path already matched + judged the replies) it is rendered as-is;
313
+ * otherwise the reporter matches live replies to this review's findings itself
314
+ * (annotate mode). Either way the feedback path fails soft — it never blocks the
315
+ * comment from being posted.
316
+ */
317
+ async report(review, feedback) {
88
318
  // Carry forward any per-PR dismissals recorded in the existing comment so they
89
319
  // survive re-reviews (a dismissed finding stays in the collapsed section).
90
320
  const existing = await this.findExistingComment();
91
- const dismissed = existing
92
- ? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
93
- : [];
321
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
322
+ const dismissed = state?.dismissed ?? [];
323
+ const withFp = review.findings.map((finding) => ({
324
+ finding,
325
+ fp: fingerprintFinding(finding),
326
+ }));
327
+ // The `/undismiss` pins carry on every render, whether or not this run matched a
328
+ // reply for the pinned finding — the records may come and go, the pin does not.
329
+ const priorRecords = state?.feedback ?? [];
330
+ const pinsIn = collectPins(state?.pins, priorRecords);
331
+ const { records, pins } = feedback
332
+ ? applyPins(feedback, pinsIn)
333
+ : await this.computeFeedback(withFp, priorRecords, pinsIn);
94
334
  const link = await this.linkContextAsync();
95
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
335
+ await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
96
336
  }
97
337
  /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
98
- async reportAggregate(results, unmatchedFiles) {
338
+ async reportAggregate(results, unmatchedFiles, feedback) {
99
339
  const existing = await this.findExistingComment();
100
- const dismissed = existing
101
- ? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
102
- : [];
340
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
341
+ const dismissed = state?.dismissed ?? [];
342
+ // Feedback is keyed by the SAME scope-namespaced id the aggregate comment
343
+ // renders, so a record can never drift across scopes.
344
+ const withFp = results.flatMap((result) => result.review.findings.map((finding) => ({
345
+ finding,
346
+ fp: scopedFingerprint(result.isDefault ? null : result.scope, finding),
347
+ })));
348
+ const priorRecords = state?.feedback ?? [];
349
+ const pinsIn = collectPins(state?.pins, priorRecords);
350
+ const { records, pins } = feedback
351
+ ? applyPins(feedback, pinsIn)
352
+ : await this.computeFeedback(withFp, priorRecords, pinsIn);
103
353
  const link = await this.linkContextAsync();
104
- await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, {
105
- unmatchedFiles,
106
- }));
354
+ await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, { unmatchedFiles }, records, pins));
355
+ }
356
+ /**
357
+ * Pair each matched PR reply with the finding it answers and its raw reply text —
358
+ * the input the adjudication step (core/review.ts) judges against the source. Any
359
+ * verdict already decided in the prior comment state is carried forward (so a reply
360
+ * is not re-judged for the same words). The reply text rides only the transient
361
+ * AdjudicationItem: it feeds the adjudicator prompt and is never stored on a record
362
+ * or rendered (see FeedbackRecordSchema). Gated on `mode !== "off"`. A fetch/parse
363
+ * error THROWS — the caller (runReview's feedback step) catches it and continues
364
+ * without records, which lets report() fall back to computeFeedback and preserve
365
+ * the previously recorded state; returning `[]` here would wipe it instead.
366
+ *
367
+ * `fpOf` MUST key findings the same way the comment this review lands in stores its
368
+ * feedback: scope-namespaced for the aggregate (comment:'single') comment, plain
369
+ * for a single/per-scope one. A mismatch would leave every fresh record keyed
370
+ * differently from the prior state, so mergeFeedback would carry no verdict and the
371
+ * budget would be re-spent judging the same words each run.
372
+ */
373
+ async matchAdjudicationItems(review, fpOf = fingerprintFinding) {
374
+ const config = this.options.feedback;
375
+ if (!config || config.mode === "off") {
376
+ return [];
377
+ }
378
+ // Deliberately NOT wrapped in a swallow-all: a fetch error here must propagate so
379
+ // runReview's own catch leaves `feedback` undefined — then report() falls back to
380
+ // computeFeedback, whose error path preserves the previously recorded state. A
381
+ // caught `[]` instead reads downstream as "there are no replies" and would wipe
382
+ // prior annotations (and un-hide reply-cleared findings) on a transient API error.
383
+ const existing = await this.findExistingComment();
384
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
385
+ const replies = await this.replyComments();
386
+ return buildAdjudicationItems(review, state?.feedback ?? [], replies, fpOf, config.match, this.options.headSha, state?.pins);
387
+ }
388
+ /**
389
+ * Read what humans pushed back on for THIS PR from the comment already posted:
390
+ * decode its embedded findings, match the non-bot replies, and merge any prior
391
+ * verdict. Works retroactively — the bot comment embeds its own findings, so no
392
+ * re-review is needed. `ecr feedback` crawls history through this. Fails soft:
393
+ * missing/unparseable comment ⇒ empty result, never a throw.
394
+ */
395
+ async collectFeedback() {
396
+ try {
397
+ const existing = await this.findExistingComment();
398
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
399
+ if (!state) {
400
+ return { findings: [], records: [] };
401
+ }
402
+ // An aggregate comment namespaces ids per scope; a single comment does not.
403
+ const withFp = state.scopes && state.scopes.length > 0
404
+ ? state.scopes.flatMap((scope) => scope.review.findings.map((finding) => ({
405
+ finding,
406
+ fp: scopedFingerprint(scope.isDefault ? null : scope.scope, finding),
407
+ })))
408
+ : state.review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
409
+ const replies = await this.replyComments();
410
+ // The retroactive crawl reports on any recorded pushback regardless of
411
+ // `mode`; only the match strategy is honored (default "both"). The crawl reviews
412
+ // nothing, so it has no head SHA to compare against: stored verdicts do not carry
413
+ // here. That costs the report nothing — it renders who replied and where, never a
414
+ // verdict — and keeps "unknown source" uniformly non-carrying.
415
+ const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match: this.options.feedback?.match ?? "both" }), state.feedback ?? [], this.options.headSha), collectPins(state.pins, state.feedback ?? []));
416
+ return { findings: withFp, records };
417
+ }
418
+ catch {
419
+ return { findings: [], records: [] };
420
+ }
421
+ }
422
+ // @ref LLP 0011#deterministic-matching [implements] — replies are matched to findings deterministically; the reporter never lets a model pick which finding a reply answers
423
+ /**
424
+ * The matched, merged feedback records to render, or none. Gated on
425
+ * `mode !== "off"` (the annotate/adjudicate switch) and wrapped so a fetch or
426
+ * match error degrades to the records already recorded — a review is never
427
+ * blocked by the feedback path.
428
+ */
429
+ async computeFeedback(withFp, previous, pinsIn) {
430
+ const config = this.options.feedback;
431
+ // `applied` is a function of the CURRENT config, never a stored fact: a carried
432
+ // record's flag was computed under the config of the run that stored it, so a
433
+ // repo flipping `dismiss` back to "never" (or `mode` to "off") must un-hide the
434
+ // finding on the next render, not keep honoring the old policy.
435
+ const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
436
+ const reapply = (records) => {
437
+ // Stamp the pins first: `feedbackApplied` reads the pin off the record, and the
438
+ // set — not the record — is what says which finding a maintainer restored.
439
+ const stamped = applyPins(records, pinsIn);
440
+ return {
441
+ pins: stamped.pins,
442
+ records: stamped.records.map((record) => {
443
+ const finding = findingByFp.get(record.fp);
444
+ // With no feedback config at all, the feature is disabled, so nothing is
445
+ // applied — but the record itself (who replied, any verdict) is preserved.
446
+ if (!config) {
447
+ return { ...record, applied: false };
448
+ }
449
+ return finding
450
+ ? { ...record, applied: feedbackApplied(finding, record, config) }
451
+ : record;
452
+ }),
453
+ };
454
+ };
455
+ // mode "off" (or no config) preserves the previously recorded records and only
456
+ // stops matching NEW replies. Returning [] here would let render's reviewState
457
+ // drop the feedback key entirely, permanently losing every recorded reply, verdict
458
+ // and reply-dismissal decision — mirror applyDismissal, which keeps feedback on a
459
+ // re-render. `applied` is still recomputed (→ false under "off"), so a finding a
460
+ // reply had cleared correctly returns to the active list.
461
+ if (!config || config.mode === "off") {
462
+ return reapply(previous);
463
+ }
464
+ try {
465
+ const replies = await this.replyComments();
466
+ return reapply(mergeFeedback(matchReplies(replies, withFp, { match: config.match }), previous, this.options.headSha));
467
+ }
468
+ catch {
469
+ return reapply(previous);
470
+ }
471
+ }
472
+ // @ref LLP 0008#github-reporter-identity [implements] — a reply is any comment NOT authored by us and NOT carrying our marker; author identity (user.login) is what excludes our own, never the forgeable marker alone
473
+ /**
474
+ * The PR's human replies, as the matcher consumes them. Excludes comments we
475
+ * authored (by unspoofable `user.login`) and any comment carrying our marker, so
476
+ * our own footer is never read back as a reply; the `author_association` gives
477
+ * the maintainer flag and the PR author's login gives the `author` flag. When our
478
+ * login can't be resolved, the marker filter still keeps our own comments out.
479
+ *
480
+ * The PR-author lookup is an extra `gh` call, so it only runs when the flag can
481
+ * actually change an outcome — `dismiss: "adjudicated"` (see feedbackNeedsPrAuthor).
482
+ * Skipping it leaves every reply `author: false`, the same fail-closed answer a
483
+ * failed lookup gives, and under any other `dismiss` value nothing reads the flag.
484
+ */
485
+ async replyComments() {
486
+ const [comments, ownLogin, prAuthor] = await Promise.all([
487
+ this.fetchAllComments(),
488
+ this.resolveOwnLogin(),
489
+ feedbackNeedsPrAuthor(this.options.feedback) ? this.resolvePrAuthor() : null,
490
+ ]);
491
+ const out = [];
492
+ for (const comment of comments) {
493
+ const login = comment.user?.login;
494
+ if (!login || (ownLogin && login === ownLogin)) {
495
+ continue;
496
+ }
497
+ if (comment.body?.includes(this.marker)) {
498
+ continue;
499
+ }
500
+ out.push({
501
+ id: comment.id,
502
+ body: comment.body ?? "",
503
+ login,
504
+ maintainer: MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""),
505
+ // Trusted-for-adjudication identity: the reply is from the PR author. Derived
506
+ // from the unspoofable comment author, not the (public) marker or reply text.
507
+ author: prAuthor != null && login === prAuthor,
508
+ ...(comment.html_url ? { url: comment.html_url } : {}),
509
+ });
510
+ }
511
+ return out;
107
512
  }
108
513
  /**
109
514
  * The embedded review state of the existing reviewer comment, or null when no
@@ -173,8 +578,13 @@ export class GitHubReporter {
173
578
  }
174
579
  /**
175
580
  * Add or remove per-PR finding dismissals in the reviewer's comment and re-render
176
- * it in place — no re-review needed (the comment embeds the full review state).
581
+ * it in place — no re-review needed (the comment embeds the full review state). The
582
+ * decision itself is pure: see applyDismissalToState, which also re-derives every
583
+ * feedback record's `applied` under the current config and carries the `/undismiss`
584
+ * pin set. This is a render like any other, so it must never leave a record hidden
585
+ * under a policy the repo has since changed.
177
586
  */
587
+ // @ref LLP 0008#comment-lifecycle [implements] — validates added fingerprints against the CURRENT review's valid set, scope-aware (scoped fingerprints for an aggregate comment, plain otherwise), rather than trusting caller-supplied ids
178
588
  async applyDismissal(add, remove, by, reason) {
179
589
  const existing = await this.findExistingComment();
180
590
  if (!existing) {
@@ -184,26 +594,18 @@ export class GitHubReporter {
184
594
  if (!state) {
185
595
  throw new Error("The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.");
186
596
  }
187
- // Scope-aware validity: on an aggregate comment the ids are scope-namespaced, so
188
- // validate against every scope's scoped fingerprints; otherwise the plain ones.
189
597
  const isAggregate = Array.isArray(state.scopes) && state.scopes.length > 0;
190
- const validFps = isAggregate
191
- ? new Set(state.scopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding))))
192
- : new Set(state.review.findings.map(fingerprintFinding));
193
- const matched = add.filter((fp) => validFps.has(fp));
194
- const unmatched = add.filter((fp) => !validFps.has(fp));
195
- const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
196
- for (const fp of matched) {
197
- if (!dismissed.some((record) => record.fp === fp)) {
198
- dismissed.push({ fp, by, reason });
199
- }
200
- }
598
+ const next = applyDismissalToState(state, add, remove, this.options.feedback, by, reason);
201
599
  const link = await this.linkContextAsync();
202
600
  const body = isAggregate
203
- ? renderAggregateMarkdown(state.scopes, this.options.commentTag, dismissed, link)
204
- : renderMarkdown(state.review, this.options.commentTag, dismissed, link);
601
+ ? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
602
+ : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
205
603
  await this.patchComment(existing.id, body);
206
- return { dismissedCount: dismissed.length, matched, unmatched };
604
+ return {
605
+ dismissedCount: next.dismissed.length,
606
+ matched: next.matched,
607
+ unmatched: next.unmatched,
608
+ };
207
609
  }
208
610
  /** Newest comment WE authored carrying our marker (id + body), or null if none. */
209
611
  async findExistingComment() {
@@ -214,6 +616,33 @@ export class GitHubReporter {
214
616
  // Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
215
617
  // pathological PR; virtually every real PR exits far earlier.
216
618
  static MAX_COMMENT_PAGES = 30;
619
+ /** How long a fetched comment list may be reused (see fetchAllComments). */
620
+ static COMMENTS_CACHE_TTL_MS = 30_000;
621
+ commentsCache;
622
+ /**
623
+ * Burst-collapsing cache over fetchAllCommentsUncached. One logical operation
624
+ * fans out into several comment reads (readState + collectFeedback in the
625
+ * `ecr feedback` crawl; findExistingComment + replyComments + upsert in a
626
+ * report) that would each re-fetch the identical paginated list. The TTL is
627
+ * deliberately short: across a real gap (break-glass check → 30-minute review →
628
+ * post) a stale list could miss a fresh `/skip-review` or a new duplicate, so
629
+ * only back-to-back calls coalesce. Any comment mutation invalidates it.
630
+ */
631
+ fetchAllComments() {
632
+ const now = Date.now();
633
+ if (!this.commentsCache || now - this.commentsCache.at > GitHubReporter.COMMENTS_CACHE_TTL_MS) {
634
+ const comments = this.fetchAllCommentsUncached().catch((error) => {
635
+ // Never cache a failure.
636
+ this.commentsCache = undefined;
637
+ throw error;
638
+ });
639
+ this.commentsCache = { at: now, comments };
640
+ }
641
+ return this.commentsCache.comments;
642
+ }
643
+ invalidateComments() {
644
+ this.commentsCache = undefined;
645
+ }
217
646
  /**
218
647
  * Fetch ALL issue comments, paginating manually (a single page's array is valid
219
648
  * JSON; `--paginate` concatenates arrays into invalid JSON). The issue-comments
@@ -222,7 +651,8 @@ export class GitHubReporter {
222
651
  * a recent `/skip-review` can otherwise fall outside a single 100-comment window,
223
652
  * causing duplicate comments and missed break-glass).
224
653
  */
225
- async fetchAllComments() {
654
+ // @ref LLP 0008#comment-lifecycle [constrained-by] — the issue-comments endpoint ignores sort/direction and returns oldest-first; pagination must reach the end or the newest comment (ours, or a recent break-glass) can fall outside the window
655
+ async fetchAllCommentsUncached() {
226
656
  const all = [];
227
657
  const gh = await resolveTrustedTool("gh");
228
658
  for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
@@ -258,6 +688,7 @@ export class GitHubReporter {
258
688
  * otherwise create it. Comments come back oldest-first, so the LAST marked one
259
689
  * is the newest and is the keeper.
260
690
  */
691
+ // @ref LLP 0008#comment-lifecycle [implements] — converges to one live comment: patches the newest own-marker comment and deletes older duplicates, never touching a look-alike from someone else
261
692
  async upsertComment(body) {
262
693
  // Update/clean up only comments WE authored, never a look-alike posted by
263
694
  // someone else (see selectOwnComments) — otherwise the newest forged marker
@@ -290,6 +721,7 @@ export class GitHubReporter {
290
721
  }
291
722
  }
292
723
  async createComment(body) {
724
+ this.invalidateComments();
293
725
  const gh = await resolveTrustedTool("gh");
294
726
  await this.withBodyFile(body, (jsonPath) => run(gh, [
295
727
  "api",
@@ -301,6 +733,7 @@ export class GitHubReporter {
301
733
  ], { cwd: this.options.cwd }));
302
734
  }
303
735
  async patchComment(commentId, body) {
736
+ this.invalidateComments();
304
737
  const gh = await resolveTrustedTool("gh");
305
738
  await this.withBodyFile(body, (jsonPath) => run(gh, [
306
739
  "api",
@@ -312,6 +745,7 @@ export class GitHubReporter {
312
745
  ], { cwd: this.options.cwd }));
313
746
  }
314
747
  async deleteComment(commentId) {
748
+ this.invalidateComments();
315
749
  const gh = await resolveTrustedTool("gh");
316
750
  await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
317
751
  }
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0008#terminal-reporter — stdout carries the report (progress/logging goes to stderr) and the decision maps to a process exit code for pre-push/pre-commit gating
1
2
  import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings } from "../core/render.js";
2
3
  import { SEVERITIES } from "../core/schema.js";
3
4
  const ESC = "";
@@ -24,6 +25,7 @@ export class TerminalReporter {
24
25
  color;
25
26
  constructor(options = {}) {
26
27
  this.options = options;
28
+ // @ref LLP 0008#terminal-reporter [constrained-by] — TTY-gated, not just "not --json": coloring based on !json alone would corrupt piped/redirected output with ANSI codes
27
29
  this.color = !options.json && Boolean(process.stdout.isTTY);
28
30
  }
29
31
  async report(review) {
@@ -49,6 +51,14 @@ export class TerminalReporter {
49
51
  }
50
52
  out.push("");
51
53
  }
54
+ const setupNotes = review.setupNotes ?? [];
55
+ if (setupNotes.length > 0) {
56
+ out.push(this.paint(BOLD, "🔗 Review setup:"));
57
+ for (const note of setupNotes) {
58
+ out.push(this.paint(DIM, ` - ${note}`));
59
+ }
60
+ out.push("");
61
+ }
52
62
  if (review.findings.length === 0) {
53
63
  out.push(this.paint(DIM, "No findings."), "");
54
64
  }