@expo/code-review-cli 0.14.0 → 0.15.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.
@@ -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)) {