@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 +40 -2
- package/build/commands/ci.js +16 -1
- package/build/commands/dismiss.js +2 -0
- package/build/commands/post-review.js +1 -0
- package/build/commands/review.js +3 -0
- package/build/config/load.js +8 -0
- package/build/config/schema.js +20 -0
- package/build/core/claude-code.js +1 -0
- 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 +18 -0
- package/build/research-mcp/providers.js +48 -0
- package/build/research-mcp/remote-search.js +36 -0
- package/build/research-mcp/server.js +2 -2
- package/build/research-mcp/types.js +10 -0
- package/package.json +1 -1
- package/templates/config.jsonc +16 -0
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.
|
|
278
|
-
|
|
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
|
|
package/build/commands/ci.js
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/build/commands/review.js
CHANGED
|
@@ -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;
|
package/build/config/load.js
CHANGED
|
@@ -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
|
};
|
package/build/config/schema.js
CHANGED
|
@@ -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
|
}
|
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)) {
|