@expo/code-review-cli 0.13.0 → 0.14.2

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 CHANGED
@@ -274,8 +274,8 @@ The boundary, in brief:
274
274
  many discovery and page fetches, so each call reports its own request ledger and
275
275
  the run reports totals.
276
276
  - **Root-only in routed monorepos** (it starts a host process); scope configs
277
- cannot alter it. Result-cache reuse is disabled while research is enabled,
278
- because web content can change without a config change.
277
+ cannot alter it. Research results are fetched and audited per run; cached review
278
+ results remain keyed by the ordinary trusted review inputs and make no network call.
279
279
 
280
280
  Full detail — providers, query grammar, `fetch_platform_doc` modes, provenance and
281
281
  citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
@@ -651,6 +651,44 @@ the defaults are asymmetric: [LLP 0011](./llp/0011-author-feedback.explainer.md)
651
651
 
652
652
  </details>
653
653
 
654
+ <details>
655
+ <summary><b>Inline PR comments (findings on the diff line)</b></summary>
656
+
657
+ Off by default. When enabled, each finding anchored to a line that is actually in
658
+ the PR's diff is posted as an inline review comment on that line, carrying the
659
+ full rationale, sources, and suggestion. The main comment still lists every
660
+ finding, but an inlined one renders short — title, links, category, id, and a
661
+ one-line rationale — plus a `💬 inline comment` link to the thread. The main
662
+ comment remains the single durable state store: `/dismiss`, embedded state, and
663
+ reply adjudication are unchanged.
664
+
665
+ ```jsonc
666
+ "inline": {
667
+ "enabled": false,
668
+ "maxComments": 20 // per PR (single comment mode) / per scope (per-scope mode)
669
+ }
670
+ ```
671
+
672
+ Behavior notes:
673
+
674
+ - **Threads converge, never churn.** Each inline comment carries a per-finding
675
+ marker and is patched in place across re-reviews. The cap is sticky: a live
676
+ thread is never evicted to create a new one. A finding that leaves the set is
677
+ stubbed if humans replied in its thread (never deleted), deleted only when bare.
678
+ - **Fail-safe teardown.** A failed run, a `--scopes` partial run, or a truncated
679
+ aggregate never tears threads down — teardown only runs when the target set is
680
+ provably complete. Creates are skipped (and logged) when the PR head moved
681
+ during the review.
682
+ - **Replies on inline threads count as author feedback.** Replying in a thread
683
+ matches the reply to that finding structurally; clearing still requires the
684
+ `id:…` token in the replier's own words, same as main-comment replies.
685
+ - **Notification fan-out.** The first enabled run on a PR sends one notification
686
+ per created inline comment; `maxComments` bounds it.
687
+ - **Disabling later** leaves existing threads up (the sync no longer runs); a
688
+ comment-mode switch — or the reporter's clear path — stubs/removes them.
689
+
690
+ </details>
691
+
654
692
  <details>
655
693
  <summary><b>External context (--context-file)</b></summary>
656
694
 
@@ -446,6 +446,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
446
446
  breakGlassMarker: config.breakGlassMarker,
447
447
  cwd,
448
448
  feedback: config.feedback,
449
+ inline: config.inline,
449
450
  headSha,
450
451
  });
451
452
  try {
@@ -554,6 +555,10 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
554
555
  findings: [],
555
556
  summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
556
557
  incomplete: [],
558
+ // A failed run's empty findings list proves nothing: this flag is what keeps
559
+ // the inline sync additive-only (no thread teardown) — and it renders the
560
+ // accurate "No review" decision label.
561
+ couldNotComplete: true,
557
562
  });
558
563
  }
559
564
  catch (postError) {
@@ -568,6 +573,10 @@ function failureReview(scopeName, reason) {
568
573
  findings: [],
569
574
  summary: `⚠️ The AI reviewer failed to run for scope **${scopeName}**, so those changes were **not** reviewed:\n\n> ${reason}`,
570
575
  incomplete: [],
576
+ // Empty-but-not-clean: keeps the inline sync additive-only for the whole
577
+ // aggregate (a failed scope's findings may still be live on the PR) and renders
578
+ // the accurate "No review" label in the scope table.
579
+ couldNotComplete: true,
571
580
  };
572
581
  }
573
582
  // @ref LLP 0007#routed-ci-fan-out [implements] — root tag wins; sequential per-scope budgets; partial --scopes merges prior state
@@ -704,6 +713,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
704
713
  // Root-only feedback config (see loadScopeConfig): lets a reporter posting with
705
714
  // no explicit records match replies itself (annotate mode) at report time.
706
715
  feedback: rootConfig.feedback,
716
+ inline: rootConfig.inline,
707
717
  headSha,
708
718
  });
709
719
  // comment:'single' mode: every active scope's feedback seam AND the final
@@ -900,7 +910,12 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
900
910
  const aggFeedback = feedbackNeedsRunSeam(rootConfig.feedback)
901
911
  ? mergeAggregateFeedback(results, finalResults, aggState?.feedback ?? [], rootConfig.feedback, headSha, aggState?.pins)
902
912
  : undefined;
903
- await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback);
913
+ // A --scopes partial run carries the other scopes out of (possibly truncated)
914
+ // embedded state, so its target set is incomplete: inline teardown must not
915
+ // run (failed scopes are handled inside reportAggregate itself).
916
+ await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback, {
917
+ inlineTeardown: !scopesFilter,
918
+ });
904
919
  }
905
920
  // Clean up any per-scope comments from a previous per-scope run. A partial run
906
921
  // only ever touches the named scopes' comments.
@@ -84,6 +84,8 @@ export async function dismissCommand(argv, mode) {
84
84
  // feedback policy in force now — without it a reply-cleared finding would be
85
85
  // un-hidden by an unrelated /dismiss.
86
86
  feedback: config.feedback,
87
+ // Lets the dismissal stub/revive the finding's inline thread (additive-only).
88
+ inline: config.inline,
87
89
  });
88
90
  const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
89
91
  if (mode === "add") {
@@ -113,6 +113,7 @@ export async function postReviewCommand(argv) {
113
113
  breakGlassMarker: config.breakGlassMarker,
114
114
  cwd,
115
115
  feedback: config.feedback,
116
+ inline: config.inline,
116
117
  headSha,
117
118
  });
118
119
  const result = await publishDeferredReview(reporter, artifact, async () => {
@@ -242,6 +242,8 @@ export async function reviewCommand(argv) {
242
242
  cwd,
243
243
  // Root-only feedback config (loadScopeConfig inherits it from the root).
244
244
  feedback: config.feedback,
245
+ // Root-only too (loadScopeConfig inherits it from the root).
246
+ inline: config.inline,
245
247
  headSha: await reviewedHeadSha(source),
246
248
  })
247
249
  : null;
@@ -315,6 +317,7 @@ export async function reviewCommand(argv) {
315
317
  breakGlassMarker: config.breakGlassMarker,
316
318
  cwd,
317
319
  feedback: config.feedback,
320
+ inline: config.inline,
318
321
  headSha,
319
322
  })
320
323
  : null;
@@ -26,6 +26,11 @@ const FEEDBACK_CONFIG_DEFAULTS = {
26
26
  protectedCategories: ["secrets", "security"],
27
27
  maxAdjudications: 10,
28
28
  };
29
+ /** Inline-comment config for a scope load (where `inline` is schema-rejected). */
30
+ const INLINE_CONFIG_DEFAULTS = {
31
+ enabled: false,
32
+ maxComments: 20,
33
+ };
29
34
  /** Research defaults for a scope load (where `research` is schema-rejected). */
30
35
  const RESEARCH_CONFIG_DEFAULTS = {
31
36
  enabled: false,
@@ -162,6 +167,8 @@ async function loadConfigDir(dir, schema) {
162
167
  // for a scope config and the defaults stand in (unused — the command layer
163
168
  // reads the ROOT config's feedback values; the comment lifecycle is global).
164
169
  feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
170
+ // Root-only, same reasoning as feedback.
171
+ inline: parsed.inline ?? INLINE_CONFIG_DEFAULTS,
165
172
  };
166
173
  return { config, raw: rawObject };
167
174
  }
@@ -296,6 +303,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
296
303
  // run the default policy instead of the repo's real one.
297
304
  stack: rootConfig.stack,
298
305
  feedback: rootConfig.feedback,
306
+ inline: rootConfig.inline,
299
307
  research: rootConfig.research,
300
308
  scopeName: scope.name,
301
309
  };
@@ -216,6 +216,20 @@ export const ReviewConfigSchema = z.object({
216
216
  protectedCategories: ["secrets", "security"],
217
217
  maxAdjudications: 10,
218
218
  }),
219
+ // Inline PR review comments for findings anchored to a line in the diff: the
220
+ // main comment stays the durable state store and renders such findings short,
221
+ // linking to the inline thread. ROOT-ONLY (the comment lifecycle is global) and
222
+ // off by default — a feature that mutates N comments per run earns trust with
223
+ // field data first. Turning it off later leaves existing threads up (documented);
224
+ // clear() still sweeps them on comment-mode switches.
225
+ inline: z
226
+ .object({
227
+ enabled: z.boolean().default(false),
228
+ // Cap on inline comments PER REPORTER: per PR in single/legacy comment mode,
229
+ // per scope in per-scope mode. Bounds create-notification fan-out and API use.
230
+ maxComments: z.number().int().positive().default(20),
231
+ })
232
+ .default({ enabled: false, maxComments: 20 }),
219
233
  });
220
234
  /** One routing scope: ordered globs → a directory containing .expo-code-review/. */
221
235
  export const RoutingScopeSchema = z.object({
@@ -317,6 +331,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
317
331
  stack: true,
318
332
  feedback: true,
319
333
  research: true,
334
+ inline: true,
320
335
  }).extend({
321
336
  auth: z
322
337
  .never({ error: "auth is locked to the root config; remove it from this scope config" })
@@ -342,4 +357,9 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
342
357
  error: "research is locked to the root config because it starts a trusted host process; remove it from this scope config",
343
358
  })
344
359
  .optional(),
360
+ inline: z
361
+ .never({
362
+ error: "inline is locked to the root config (the comment lifecycle is global); remove it from this scope config",
363
+ })
364
+ .optional(),
345
365
  });
@@ -448,6 +448,7 @@ export function parseClaudeResult(stdout) {
448
448
  const tokens = {
449
449
  input: num(usage.input_tokens),
450
450
  output: num(usage.output_tokens),
451
+ reasoning: num(usage.output_tokens_details?.thinking_tokens),
451
452
  cache: {
452
453
  write: num(usage.cache_creation_input_tokens),
453
454
  read: num(usage.cache_read_input_tokens),
@@ -33,6 +33,10 @@ export function reviewPostingConfigFingerprint(config) {
33
33
  commentTag: config.commentTag,
34
34
  breakGlassMarker: config.breakGlassMarker,
35
35
  feedback: config.feedback,
36
+ // Inline comments change what posting does (N review-comment mutations), so a
37
+ // saved review must not post under a different inline policy than it was
38
+ // saved with.
39
+ inline: config.inline,
36
40
  }))
37
41
  .digest("hex");
38
42
  }
@@ -67,6 +67,27 @@ export function groupBySeverity(findings) {
67
67
  export function commentMarker(tag) {
68
68
  return `<!-- ${tag} -->`;
69
69
  }
70
+ /**
71
+ * Per-finding identity marker of an inline (PR review) comment, always rendered at
72
+ * byte 0 of the body. Substring-safe against `commentMarker(tag)` and any derived
73
+ * scope tag (`<tag>:<scope>`) in both directions — the closing ` -->` differs —
74
+ * including a scope literally named `inline`.
75
+ */
76
+ export function inlineCommentMarker(tag, fp) {
77
+ return `<!-- ${tag}:inline:fp=${fp} -->`;
78
+ }
79
+ /**
80
+ * The fingerprint an inline comment of OURS carries, or null. Anchored at byte 0
81
+ * and constrained to the fingerprint alphabet, so a marker echoed later in a body
82
+ * (untrusted prose, a quote-reply) can never parse as identity — mechanical
83
+ * safety, not just the incidental "our renderer emits it first". Identity itself
84
+ * is author + this marker (see the reporter); the parse alone proves nothing.
85
+ */
86
+ export function parseInlineMarkerFp(body, tag) {
87
+ const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
+ const match = body.match(new RegExp(`^<!-- ${escapedTag}:inline:fp=([a-f0-9]{6,64}) -->`));
89
+ return match ? match[1] : null;
90
+ }
70
91
  // @ref LLP 0011#forged-state-markers [implements] — the first-match parsers make an earlier forged marker win, so untrusted prose never keeps a raw `<!--`
71
92
  /**
72
93
  * Neutralize anything that could impersonate this reviewer's embedded state
@@ -233,7 +254,41 @@ function sourceLabel(value) {
233
254
  .replace(/>/g, "&gt;")
234
255
  .replace(/([\\[\]])/g, "\\$1");
235
256
  }
257
+ /** Longest one-line rationale excerpt the short form keeps in the main comment. */
258
+ const SHORT_RATIONALE_CHARS = 160;
259
+ /**
260
+ * A rationale reduced to one plain line for the short (inlined) form: whitespace
261
+ * collapsed, every `<` escaped so a truncated HTML block (`<details>`) can never
262
+ * leave an unclosed tag in the comment, cut at a word boundary with an ellipsis.
263
+ * This excerpt is the audit trail that survives the PR author collapsing or
264
+ * resolving the inline thread, so it must always be non-empty when the rationale is.
265
+ */
266
+ export function oneLineRationale(text) {
267
+ const flat = text.replace(/</g, "&lt;").replace(/\s+/g, " ").trim();
268
+ if (flat.length <= SHORT_RATIONALE_CHARS) {
269
+ return flat;
270
+ }
271
+ const cut = flat.slice(0, SHORT_RATIONALE_CHARS);
272
+ const atWord = cut.slice(0, cut.lastIndexOf(" "));
273
+ return `${atWord.length > 0 ? atWord : cut}…`;
274
+ }
275
+ /** Only ever link to github.com from a stored/returned URL (see REPLY_URL_RE). */
276
+ function validInlineUrl(link, id) {
277
+ const url = link?.inlineUrls?.get(id);
278
+ return url && REPLY_URL_RE.test(url) ? url : null;
279
+ }
236
280
  function renderFindingLines(finding, link, id = fingerprintFinding(finding), reply) {
281
+ const inlineUrl = validInlineUrl(link, id);
282
+ if (inlineUrl) {
283
+ // Short form: the full text lives in the inline thread on the flagged line.
284
+ // The one-line rationale stays here as the durable audit trail (the author
285
+ // can collapse/resolve the thread; they cannot edit this comment).
286
+ return [
287
+ `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\` · 💬 [inline comment](${inlineUrl})${replyAnnotation(reply)}`,
288
+ ...indentContinuation(oneLineRationale(stripStateMarkers(finding.rationale))),
289
+ "",
290
+ ];
291
+ }
237
292
  const out = [
238
293
  `- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
239
294
  ...indentContinuation(stripStateMarkers(finding.rationale)),
@@ -255,6 +310,47 @@ function renderFindingLines(finding, link, id = fingerprintFinding(finding), rep
255
310
  out.push("");
256
311
  return out;
257
312
  }
313
+ /**
314
+ * Full body of one inline (PR review) finding comment: identity marker at byte 0,
315
+ * then the finding rendered whole — everything model-written passes
316
+ * stripStateMarkers, same as the main comment. The footer teaches the clearing
317
+ * flow (an UNQUOTED id citation), since the main comment only shows the short form.
318
+ * The body must never contain `:state=`/`:fingerprints=` (it would be dropped as
319
+ * our own main comment by the reply matcher's backstop) — it doesn't.
320
+ */
321
+ export function renderInlineCommentBody(finding, tag, fp) {
322
+ const lines = [
323
+ inlineCommentMarker(tag, fp),
324
+ `**${severityHeading(finding.severity)}: ${stripStateMarkers(finding.title)}** _(${finding.category})_ · \`id:${fp}\``,
325
+ "",
326
+ stripStateMarkers(finding.rationale),
327
+ ];
328
+ if (finding.sources?.length) {
329
+ const sources = finding.sources
330
+ .map((source) => `[${sourceLabel(source.title)}](<${source.url}>)`)
331
+ .join(", ");
332
+ lines.push("", `**Sources:** ${sources}`);
333
+ }
334
+ if (finding.suggestion) {
335
+ lines.push("", `**Suggestion:** ${stripStateMarkers(finding.suggestion)}`);
336
+ }
337
+ lines.push("", "---", `_🤖 AI review finding — reply here to respond; write \`id:${fp}\` (outside any quote) in your reply to formally answer it. Full review in the main PR comment._`);
338
+ return lines.join("\n");
339
+ }
340
+ /**
341
+ * What a no-longer-tracked inline comment is patched to when its thread has human
342
+ * replies (a bare one is deleted instead). Deliberately NEUTRAL: a finding leaves
343
+ * the inline set for many reasons that are not resolution — capped out, its line
344
+ * left the diff, aggregate-state truncation — so this text must never assert
345
+ * "resolved" or "dismissed". The marker stays, so a returning finding revives the
346
+ * same thread instead of opening a new one.
347
+ */
348
+ export function inlineStubBody(tag, fp) {
349
+ return [
350
+ inlineCommentMarker(tag, fp),
351
+ "_🤖 This finding is no longer tracked inline — see the main review comment for current status._",
352
+ ].join("\n");
353
+ }
258
354
  // @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
259
355
  /** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
260
356
  function setupNote(notes = []) {
@@ -15,6 +15,21 @@ const FINDING_ID_RE = /\bid:([a-f0-9]{6,64})\b/gi;
15
15
  * would let the review answer itself, so this is the tag-independent backstop.
16
16
  */
17
17
  const OWN_COMMENT_RE = /<!--[^\n]*:(?:state|fingerprints)=/;
18
+ /** The tag-independent shape of an inline finding comment's identity marker. */
19
+ const INLINE_MARKER_RE = /<!--[^\n]*:inline:fp=/;
20
+ /**
21
+ * Tag-independent backstop for OUR inline finding comments, which carry an
22
+ * UNQUOTED `id:<fp>` token in the body (so a maintainer can copy it into a
23
+ * reply). If the author/tag filters ever miss one — a bot-login fallback, a
24
+ * commentTag rename, a second posting identity — matching it as a reply would
25
+ * let the review cite (and, with maintainer association, CLEAR) its own
26
+ * finding. Only UNQUOTED lines count: GitHub's "Quote reply" copies our marker
27
+ * verbatim behind `> `, and that comment is a genuine human reply that must
28
+ * still be matched.
29
+ */
30
+ export function hasUnquotedInlineMarker(body) {
31
+ return body.split("\n").some((line) => !/^\s*>/.test(line) && INLINE_MARKER_RE.test(line));
32
+ }
18
33
  /**
19
34
  * Markdown → comparable text: link text without the target, no backticks or
20
35
  * emphasis marks, collapsed whitespace, lowercase, no trailing punctuation. A
@@ -60,6 +75,19 @@ export function extractQuotedLines(body) {
60
75
  }
61
76
  return out;
62
77
  }
78
+ /**
79
+ * Is `candidate` a more recent reply than `incumbent`? Issue comments carry
80
+ * positive ids; inline (PR review) replies carry NEGATED ids, so within the
81
+ * inline stream recency is the raw id's magnitude, not the signed value. The two
82
+ * sequences are independent, so cross-stream recency is undecidable — the issue
83
+ * comment wins deterministically (the main thread is the formal reply channel).
84
+ */
85
+ export function replyIsNewer(candidate, incumbent) {
86
+ if (candidate >= 0 === incumbent >= 0) {
87
+ return Math.abs(candidate) > Math.abs(incumbent);
88
+ }
89
+ return candidate >= 0;
90
+ }
63
91
  /** The `id:<hex>` tokens a comment cites, lowercased and deduped. */
64
92
  export function extractFindingIds(body) {
65
93
  const ids = [...body.matchAll(FINDING_ID_RE)].map((match) => match[1].toLowerCase());
@@ -108,7 +136,7 @@ export function matchReplies(comments, findings, opts) {
108
136
  }
109
137
  const newest = new Map();
110
138
  for (const comment of comments) {
111
- if (OWN_COMMENT_RE.test(comment.body)) {
139
+ if (OWN_COMMENT_RE.test(comment.body) || hasUnquotedInlineMarker(comment.body)) {
112
140
  continue;
113
141
  }
114
142
  const matched = new Set();
@@ -116,6 +144,11 @@ export function matchReplies(comments, findings, opts) {
116
144
  // quote-matched reply still records WHETHER it cites the finding, because that is
117
145
  // what decides clearing.
118
146
  const cited = new Set(extractCitedFindingIds(comment.body));
147
+ // An inline reply names its finding structurally — the replier chose which
148
+ // thread to answer — so it matches in every `match` mode. Never sets `citedId`.
149
+ if (comment.threadFp && known.has(comment.threadFp)) {
150
+ matched.add(comment.threadFp);
151
+ }
119
152
  if (opts.match !== "quote") {
120
153
  for (const id of extractFindingIds(comment.body)) {
121
154
  if (known.has(id)) {
@@ -139,7 +172,7 @@ export function matchReplies(comments, findings, opts) {
139
172
  }
140
173
  for (const fp of matched) {
141
174
  const previous = newest.get(fp);
142
- if (previous && previous.commentId >= comment.id) {
175
+ if (previous && !replyIsNewer(comment.id, previous.commentId)) {
143
176
  continue;
144
177
  }
145
178
  newest.set(fp, {
@@ -336,7 +336,16 @@ export function applyPins(records, pins) {
336
336
  // A pin with no recorded commentId is never lifted by a reply: "unknown" must not
337
337
  // read as "older than every comment", which would let any maintainer reply lift it.
338
338
  pin.commentId === undefined ||
339
- !records.some((record) => record.fp === pin.fp && record.maintainer === true && record.commentId > pin.commentId));
339
+ !records.some((record) => record.fp === pin.fp &&
340
+ record.maintainer === true &&
341
+ // "Posted after the pin" is only decidable within one id sequence. Issue
342
+ // comments carry positive ids and inline (PR review) replies carry NEGATED
343
+ // ones (see the reporter's replyComments); the two sequences are
344
+ // independent, so a cross-stream comparison says nothing about time — keep
345
+ // the pin. Within a stream, recency is the raw id's magnitude (negation
346
+ // inverts the signed order for inline ids).
347
+ record.commentId >= 0 === pin.commentId >= 0 &&
348
+ Math.abs(record.commentId) > Math.abs(pin.commentId)));
340
349
  const pinnedFps = new Set(kept.map((pin) => pin.fp));
341
350
  const stamped = records.map((record) => {
342
351
  if (pinnedFps.has(record.fp)) {