@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.
- package/README.md +63 -22
- package/build/commands/ci.js +18 -3
- package/build/commands/dismiss.js +2 -0
- package/build/commands/doctor.js +3 -3
- package/build/commands/init.js +6 -6
- package/build/commands/post-review.js +1 -0
- package/build/commands/review.js +3 -0
- package/build/commands/verify-config.js +3 -5
- package/build/config/load.js +44 -9
- package/build/config/schema.js +20 -0
- package/build/core/config-refs.js +12 -4
- package/build/core/deferred-review.js +4 -0
- package/build/core/render.js +96 -0
- package/build/core/responses.js +35 -2
- package/build/core/schema.js +10 -1
- package/build/reporters/github.js +585 -7
- package/build/research-mcp/direct-fetch.js +16 -0
- package/build/research-mcp/providers.js +44 -0
- package/build/research-mcp/remote-search.js +32 -0
- package/build/research-mcp/server.js +2 -2
- package/build/research-mcp/types.js +9 -0
- package/package.json +1 -1
- package/templates/atlantis.yml +3 -1
- package/templates/command.yml +3 -1
- package/templates/config.jsonc +16 -0
- package/templates/workflow.yml +3 -1
package/build/core/render.js
CHANGED
|
@@ -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, ">")
|
|
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, "<").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 = []) {
|
package/build/core/responses.js
CHANGED
|
@@ -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
|
|
175
|
+
if (previous && !replyIsNewer(comment.id, previous.commentId)) {
|
|
143
176
|
continue;
|
|
144
177
|
}
|
|
145
178
|
newest.set(fp, {
|
package/build/core/schema.js
CHANGED
|
@@ -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 &&
|
|
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)) {
|