Compare commits

...
Author SHA1 Message Date
Brent Vatne 813859b6cf feat: post inline PR comments for in-diff findings
Findings anchored to a line in the PR diff are posted as inline review
comments on that line (full rationale/sources/suggestion there); the main
comment renders them short — title, links, one-line rationale — with a link
to the thread. The main comment stays the single durable state store.

- Root-only config: inline { enabled: false, maxComments: 20 }
- Per-finding byte-0 marker + author = thread identity; threads patch in
  place, sticky cap never evicts a live thread, retired threads with human
  replies are stubbed (neutral text), bare ones deleted
- Teardown only when the target set is provably complete: failure reviews
  now set couldNotComplete, --scopes partial runs and /dismiss re-renders
  are additive-only (truncated aggregate state cannot prove a finding gone)
- Inline replies feed author feedback via threadFp (matches in every match
  mode; clearing still requires the unquoted id: token); review-comment ids
  are stored negated to stay collision-free vs issue-comment ids, with
  magnitude-within-stream recency and a cross-stream pin-lift refusal
- hasUnquotedInlineMarker backstop keeps our own inline bodies (which carry
  an unquoted id: token) from ever matching as replies; quoted copies still
  count as genuine quote-replies
- Creates verify the live PR head, mutations run sequentially, 403/429
  aborts the sync, every sync writes a step-summary audit line

Design + adversarial review record: PLAN-inline-comments.md
2026-08-21 12:38:34 -07:00
Brent Vatne 26acefef90 feat(research): add React Native contract providers (#76) 2026-08-20 21:10:04 -07:00
Brent Vatne 36dd50772a feat: add metrobundler.dev as a metro research provider 2026-08-14 18:34:15 -07:00
Brent Vatne d993621e62 feat: parse thinking tokens from CLI usage into tokens.reasoning 2026-08-14 18:34:15 -07:00
Peter Abbondanzo d4ed1157cc feat: add Muse Spark support (#75) 2026-08-12 20:22:23 -07:00
Brent Vatne 5c5b366d66 docs: explain what ecr solves over a bare claude -p review workflow, incl. the security model 2026-08-10 16:01:39 -07:00
Brent Vatne 17aaf2aef9 feat: render clean decisions as "Ready for human review" instead of "Approve" 2026-08-10 13:28:14 -07:00
Brent Vatne e1591cec67 docs: condense the README into a scannable overview linking to llp/ explainers 2026-08-10 13:28:14 -07:00
Brent Vatne ba9bbd79ad Let routed CI cache researched reviews too, from one shared policy (#74) 2026-08-08 18:44:00 -07:00
49 changed files with 2717 additions and 528 deletions

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
# Plan: inline PR comments for in-diff findings (rev 2, post adversarial review)
## Goal
Findings anchored to a line in the PR diff land as inline review comments on
that line. The main issue comment keeps every finding but renders an inlined
one SHORT: title + one-line rationale + link to the inline thread. The main
comment stays the single durable state store (embedded state, dismissals,
feedback records, pins, `/dismiss` target). Inline comments are a derived
presentation layer, converged on every run.
## Non-goals (v1)
- No formal `POST /pulls/{n}/reviews` review event: a batched review is atomic
(one bad anchor 422s the whole post) and cannot be upserted across runs.
Individual review comments isolate failures and PATCH in place.
- No multi-line comments; single `line` + `side: "RIGHT"` only.
- No GraphQL thread resolution, and no detection of author-resolved/outdated
threads. Accepted risk: the PR author can collapse inline threads; the main
comment therefore keeps a one-line rationale per finding (see Short form).
- `ecr feedback` retroactive crawl keeps reading issue comments only. The
review-comments fetch in `replyComments` is gated on the reporter having an
ENABLED `inline` option, so the crawl (which passes none) gains no fetches.
- Scopes removed from the routing manifest strand their comments — same
pre-existing behavior as their main comments.
- Break-glass (`postSkipNote`) leaves inline threads untouched even though it
overwrites the embedded state. Accepted, documented.
## Config
Root-only key in `ReviewConfigSchema`, locked out of scope configs with
`z.never` like `feedback`:
```jsonc
inline: {
enabled: false, // ship dark
maxComments: 20 // cap per posted comment's reporter (per PR in single/
// legacy mode; per scope in per-scope mode — documented)
}
```
Wired into `LoadedConfig`, `load.ts` defaults, all five reporter construction
sites, and `reviewPostingConfigFingerprint` (posting behavior changes with it,
so a saved deferred review must not post under a different inline policy).
Turning it OFF later leaves existing threads up (documented; `clear()` still
sweeps them on mode switches, and a manual cleanup is possible).
## Inline comment identity
Marker, at byte 0 of the body, parsed with an ANCHORED regex and a hex-only
charset:
```
<!-- <tag>:inline:fp=<[a-f0-9]{6,64}> -->
```
- Identity = author (`user.login === ownLogin`) + anchored marker; marker alone
is never identity (selectOwnComments model). ownLogin unresolvable ⇒ nothing
is ours AND no creates either (fail closed, no duplicate storms).
- Byte-0 anchoring also narrows the shared `github-actions[bot]` confused-deputy
risk: an echo-bot that prefixes any content fails the anchor.
- fp is the id the owning main comment renders (plain, or scope-namespaced for
the aggregate). Substring safety vs `<!-- tag -->` / `<!-- tag:scope -->`
holds in both directions (closing ` -->` differs), incl. a scope named
`inline`.
## Inline comment body
Marker line, then the finding rendered in full (severity, title, category,
`id:<fp>` token, rationale, sources, suggestion — every model-written field
through `stripStateMarkers`), then a footer: "Reply here to respond; write
`id:<fp>` in your reply (unquoted) to formally answer it."
**Self-reply backstop (was blocker):** the body carries an unquoted `id:` token,
and today's `OWN_COMMENT_RE` (`:state=|:fingerprints=`) would NOT exclude it if
identity/tag filters ever miss (bot-login fallback, commentTag rename, second
posting identity). Fixes, all three:
- `responses.ts` gains a tag-independent backstop: a body whose UNQUOTED lines
contain `:inline:fp=` is never treated as a reply (quoted lines excluded so a
GitHub "Quote reply" of our inline comment — which copies the marker behind
`> ` — still counts as a genuine reply).
- `replyComments` applies the same unquoted-line rule for the tag-specific
marker.
- Test: an inline body is never matched as a reply even with `ownLogin: null`
and a foreign tag; a quote-reply containing the quoted marker IS matched.
## Reporter mechanics
New option `inline?: LoadedConfig["inline"]`.
### Fetch layer
`fetchAllReviewComments()`: paginated GET `repos/{repo}/pulls/{n}/comments`
(fields: id, body, path, line, user.login, html_url, in_reply_to_id,
author_association). Cached in a MODULE-LEVEL map keyed
`prNumber\nrepo\ncwd` (the prAuthorByPr pattern) with the same 30s TTL and
invalidate-on-mutation — routed single-mode runs call `clear()` on a fresh
reporter per scope, and per-instance caching would multiply listings. Note:
this endpoint DOES honor sort/direction (unlike issue comments) — do not copy
that rationale comment; a full scan is still needed.
### Sync
`syncInline(targets, link, opts: { teardown: boolean })` runs inside
`report()` / `reportAggregate()` before rendering; returns `Map<fp, url>`.
Targets = ACTIVE findings only (not dismissed, not reply-cleared, not
requalified) with `line != null` and `diffLines.get(file).has(line)`.
**Cap (sticky, was churn bug):** order candidates by severity rank then fp
(deterministic); fill slots preferring fps that ALREADY have a live inline
comment, then admit new ones. A live thread is never evicted to create a new
one.
**Teardown gating (was blocker):** stubs/deletes run ONLY when
`opts.teardown` is true, which callers set as:
- `report()`: `!review.couldNotComplete`.
- `reportAggregate()`: caller-passed flag — ci.ts passes
`!scopesFilter && results.every(r => !r.review.couldNotComplete)`. A partial
`--scopes` run or any failed scope makes the run additive-only (creates +
patches, no stubs/deletes), because carried/truncated/failed state cannot
prove a finding is gone. (`scopedFingerprint` is opaque hex — a per-scope
stale set cannot be derived from fps, so the gate is all-or-nothing.)
- `applyDismissal`: never computes a stale set. Additive-only: stub threads of
the explicitly dismissed fps; on `/undismiss`, re-patch the full body if the
finding is present in state (skip if truncated away).
- Prereq fix: both failure literals (`ci.ts` legacy catch and
`failureReview()`) gain `couldNotComplete: true` — today they post
empty-findings reviews WITHOUT the flag and would tear everything down. Side
effects checked: the decision label becomes the accurate "No review — every
pass failed", and caching is unaffected (failures already never carry
inputHash).
**Stub text (was blocker):** neutral — "This finding is no longer tracked
inline — see the main review comment for current status." Never
"resolved/dismissed": a capped-out or drifted finding is still active.
**Mutations** (sequential, never parallel):
- create: POST with body/commit_id=headSha/path/line/side. Creates require
headSha AND a live-head check: one `gh pr view --json headRefOid` per sync
(only when creates exist); mismatch ⇒ skip creates, log (a mid-review push
means our diff no longer matches the head; a stale commit_id can silently
anchor to wrong code, and a force-push 422s every create).
- patch: only when the normalized body differs (`\r\n` → `\n`) — no PATCH-per-
finding-per-run forever.
- stale (teardown only): thread has replies ⇒ PATCH to the neutral stub
(marker kept, so a returning finding revives the thread); no replies ⇒
DELETE. Reply detection: any listed comment with `in_reply_to_id` == our id
(same snapshot; the reply-between-list-and-delete race is accepted, noted in
tests).
- duplicates (crash window): keep newest per fp, older are stale.
- 422 on create ⇒ log once + skip that finding (renders full-form). 403/429
(secondary rate limit) ⇒ log + abort ALL remaining mutations this run.
- Audit (was silent): stderr one-liner + step-summary line:
`inline sync: N created, N patched, N stubbed, N deleted, N failed`.
Every mutation individually try/caught; the inline layer never blocks the main
comment. Listing failure ⇒ empty map, no mutations.
### clear()
Extends to inline comments under this reporter's tag, gated on marker+author
only (NOT on `enabled`, so mode switches clean up after a disable): replied
threads are STUBBED, bare ones deleted — never delete a thread humans wrote in
(ci.ts calls clear() on mode switches and scope reconciliation). This also
covers the fp-namespace flip on per-scope↔single switches, symmetric with how
main comments are cleared today.
### Main-comment short form
- `LinkContext.inlineUrls?: Map<string, string>`. NEVER mutate the shared
routed-flow `link` object — render with a copy `{ ...link, inlineUrls }`.
- `renderFindingLines` with an inline URL: `- **title** —
[file:line](thread-url) _(category)_ · id:… · 💬 [inline](url)` + reply
annotation + ONE-LINE truncated rationale (~160 chars, word boundary) — the
audit trail survives the author collapsing/resolving inline threads.
Sources/suggestion/full rationale live inline only. Missing URL ⇒ full form
(tested).
- Embedded state unchanged.
### Inline replies → feedback
- `replyComments()` maps review comments too — ONLY when
`options.inline?.enabled` (keeps `ecr feedback` crawl and non-inline repos at
zero extra fetches). Excludes own login; excludes bodies whose unquoted lines
carry our markers.
- **Id namespace (was collision risk):** review-comment-sourced ReplyComments
get `id = -reviewCommentId`. Negative ids flow into FeedbackRecord.commentId
(schema unchanged — number stays a number) and are consistent across runs, so
mergeFeedback's `commentId` equality and buildAdjudicationItems' body lookup
stay collision-free vs the positive issue-comment space.
- `applyPins` gains a cross-stream guard: a pin is lifted only by a maintainer
record whose commentId is newer AND in the SAME stream (same sign). A
different-stream comparison is undecidable ordering ⇒ keep the pin (fail
closed).
- `ReplyComment.threadFp?: string`: set when the reply's `in_reply_to_id`
resolves to OUR (author+anchored-marker) top-level inline comment's fp.
`matchReplies` treats a known threadFp as a match in EVERY `match` mode
(structural signal, not text matching; documented + tested). `citedId` is
unchanged: clearing still requires the replier's own unquoted `id:` token.
- Newest-wins across streams: negative ids lose to positive ones by raw
comparison; acceptable because only the newest record per fp matters and a
same-fp cross-stream tie is rare; the applyPins guard removes the dangerous
consequence.
## Files touched
- `src/config/schema.ts`, `src/config/load.ts` — `inline` key + lock + defaults.
- `src/core/deferred-review.ts` — fingerprint includes `inline`.
- `src/reporters/github.ts` — review-comments fetch (shared cache), syncInline
+ pure exported planner, replyComments extension, clear(), applyDismissal
additive sync.
- `src/core/render.ts` — inline marker helpers (anchored parse), inline body
renderer, `LinkContext.inlineUrls`, short form + rationale truncation.
- `src/core/responses.ts` — threadFp, unquoted-marker backstop.
- `src/core/schema.ts` — applyPins same-stream guard.
- `src/commands/ci.ts` — wire config, `couldNotComplete: true` on both failure
literals, aggregate teardown flag. `review.ts`, `post-review.ts`,
`dismiss.ts` — wire config.
- Tests: planner (create/patch/stub/delete, sticky cap, dedupe, additive mode),
marker anchor/forgery, short form + missing-URL fallback, backstop vs
quote-reply, threadFp in all match modes, applyPins stream guard,
couldNotComplete literals.
- `templates/config.jsonc`, `README.md`.
## Rollout
Ship dark. Note in README: first enable on a large PR fires one notification
per created inline comment (cap bounds it). Enable on this repo first, then
expo/expo after one observed cycle.
+357 -486
View File
@@ -11,6 +11,10 @@ is the **engine** — each repo supplies its own agents and settings under
Inspired in part by Cloudflare's [_How we built our AI code review bot_](https://blog.cloudflare.com/ai-code-review/).
This README is the scannable overview. Every subsystem has a detailed design doc
under [`llp/`](./llp/) — start with
[LLP 0000](./llp/0000-expo-code-review-cli.explainer.md), the system map.
```mermaid
flowchart TD
SRC["Source<br/>local git · GitHub PR (gh)"] --> FILTER["Noise filter<br/>drop lockfiles · generated · binary"]
@@ -23,6 +27,70 @@ flowchart TD
VERIFY --> REPORT["Reporter<br/>one PR comment (CI) · terminal (local)"]
```
## Why not just `claude -p "review this diff"`?
A one-shot prompt in a workflow works — until the failure modes show up. The
engine exists for those:
- **Everything the reviewer reads is attacker-controlled.** The diff, the PR
description, and the repo files are all input an outside contributor wrote —
and a bare CLI run obeys instruction files from that same checkout
(`CLAUDE.md`, `.mcp.json`, plugins) while holding the model credential and
comment token. Here, configuration comes only from the trusted base commit,
those files are scrubbed before the engine starts, and every pass is read-only
([details below](#the-security-gap-specifically)).
- **Confident-but-wrong findings post as-is.** Raw model output is the review.
Here, every finding is quote-grounded against the real file and criticals are
adversarially verified before anything is posted.
- **Failures read as silence or approval.** A one-shot call that stalls, gets
rate-limited, or returns garbage either fails the job or posts nothing. Here,
each failure class has its own bounded retry, a failed run never renders as a
clean result, and CI always posts a terminal comment.
- **Big diffs blow the context window.** Large PRs are chunked with a separate
cross-cutting pass for multi-file issues; specialist agents run in parallel; the
prompt layout is cache-stable, and every run reports tokens, cost, and cache
hit rate.
- **Noise wastes the model's attention.** Lockfiles, generated bundles, and
binaries are filtered before the model sees them — recorded, never silently
dropped.
- **Comment spam.** One fingerprinted comment updated in place, with `/dismiss`,
severity floors, inline ignores, and author-reply tracking — not a new wall of
text per push.
- **Review prompts rot.** `ecr ref-check` fails when a prompt cites code that
moved or vanished, and every run warns loudly if a provider substituted a
different model than configured.
### The security gap, specifically
A review bot is a process holding a model credential and a GitHub write token
that reads attacker-controlled input for a living. A bare `claude -p` workflow
typically checks out the PR head and hands the model broad tools — so a PR that
says the right words can run code, read secrets, or post as you. The trust model
here ([LLP 0001](./llp/0001-trust-model.principles.md)) is built around that:
- **No PR-controlled code is ever built or executed.** The engine runs as the
published npm package via `npx`; the scaffolded workflows check out only the
base commit, with `persist-credentials: false`.
- **The model has no write tools and no ambient web.** `Read`/`Grep`/`Glob` only
— never `Bash`, `Edit`, or `WebFetch`. An injected instruction has nothing to
act with; the worst it can do is produce a wrong finding, which then has to
survive verification.
- **Review policy is not PR-editable.** Config, prompts, rosters, and the auth
mapping load from the PR's immutable base commit; `tokenEnv` is honored in
exactly one root-owned place, enforced at the schema level and again by an
independent CI guard step.
- **Credentials are compartmentalized.** Child environments are allowlists that
omit ambient keys; the research MCP never sees a model credential, the model
process never sees the search key, and outbound research queries fail closed
on credential-shaped input.
- **Untrusted text can't impersonate the reviewer.** PR prose travels inside
sanitized boundary markers, and every `<!--` in it is escaped so a forged
state marker can't hijack the dismissal list. `critical`/`secrets` findings
can't be dismissed or cleared by replies — enforced in code, not the prompt.
- **The blast radius is capped by design.** The review is advisory and
comment-only: even a fully fooled model can't approve, merge, or block
anything.
## Usage
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
@@ -52,15 +120,16 @@ npx @expo/code-review-cli doctor
The scaffolded default is **Anthropic via the Claude Code CLI**: locally your
`claude` login is enough, and for CI it helps you mint a token with
`claude setup-token`. It also handles the alternatives (an OpenAI **API key**,
a **ChatGPT/Codex subscription** sign-in). `doctor` offers to run it whenever a
credential is missing.
a **ChatGPT/Codex subscription** sign-in, or a Meta Model API key for **Muse
Spark**). `doctor` offers to run it whenever a credential is missing.
In CI, store the credential as the repo secret the scaffolded workflow forwards
(`CLAUDE_CODE_REVIEW_SHARED_API_TOKEN` by default — an `sk-ant-oat…` token from
`claude setup-token`, or an `sk-ant-api…` Console key; the CLI reads either).
Prefer **OpenAI** (API key, or a ChatGPT/Codex subscription, or both mixed) or
another provider? See [Other providers & auth modes](#other-providers) below.
Prefer **Muse Spark**, **OpenAI** (API key, or a ChatGPT/Codex subscription, or
both mixed), or another provider? See [Other providers & auth modes](#other-providers)
below.
### Reviewing (already configured)
@@ -133,219 +202,92 @@ untrusted external context; see below).
## Keeping prompts true (`ecr ref-check`)
Good reviewer prompts cite real code: "the only session entry point is
`server/src/session.ts`", "every webhook router must call `sanitizeSecrets`". Then the
code moves and the prompt keeps citing a path that no longer exists — the reviewer
reasons from a fiction, on every PR, with nothing to warn you.
`ecr ref-check` makes those citations checkable. Pin each one with a ref, in a comment
of its own (`<!-- … -->` in Markdown, `//` in JSONC):
Good reviewer prompts cite real code ("every webhook router must call
`sanitizeSecrets`"). Then the code moves and the prompt keeps citing a path that no
longer exists — the reviewer reasons from a fiction on every PR. `ecr ref-check`
makes those citations checkable: pin each one with a `@ref` comment (`<!-- … -->` in
Markdown, `//` in JSONC):
```md
<!-- @ref server/src/session.ts#createSession — the only place a session is minted -->
<!-- @ref server/src/entities/oauth/ — every provider lives here -->
<!-- @ref glob:**/*WebhookRouter.ts — the routers this rule is about -->
```
A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
never a line number, since a line number rots without any signal. Symbol anchors are
checked by whole-word match, so code moving inside its file is fine and a rename is not.
The check is strict on purpose: a backticked token in `.expo-code-review/` that looks
like a repo path **must** be a ref, because the stale citations are exactly the ones
nobody thought to annotate. A token with no extension (`eas-build-worker/terraform`,
`general-central/{module,production}`, `finops`) counts when it names something that
exists — so those get pinned too, while `anthropic/claude-opus-5`, shaped the same way,
stays prose. For a token that only looks like a path, say so once:
`<!-- @ref-ignore knex.raw() -->`. It also checks what your config already declares —
`enforceAgents` ids, scope `config` directories, scope path globs.
Refs are repo-root-relative, including in a scope's own setup dir. A scope prompt that
cites `general-central/module` for `infrastructure/general-central/module` gets told the
root-relative form to use.
never a line number (line numbers rot without any signal). The check is strict on
purpose: any backticked token in `.expo-code-review/` that names something that
exists in the repo **must** be a ref, because stale citations are exactly the ones
nobody annotated. Mark the rare false positive once with
`<!-- @ref-ignore knex.raw() -->`. Refs are always repo-root-relative, including in
a scope's own setup dir, and config declarations (`enforceAgents` ids, scope
directories and globs) are checked too.
Two run points:
- `ecr ref-check` exits 1 on any problem. Run it in CI or a pre-commit hook.
- `ecr review` / `ecr ci` run it too, and never fail a PR's checks with it. The comment
carries a **Review setup** note instead: refs that no longer resolve, plus cited code
*this PR* changes, where the ref still resolves but the guidance may not.
- `ecr review` / `ecr ci` run it too but never fail a PR's checks with it — broken
refs (and cited code *this PR* changes) surface as a **Review setup** note in the
comment.
Full detail: [LLP 0012](./llp/0012-config-ref-integrity.explainer.md).
---
## Providing context and research capabilities
## Platform research (bundled MCP)
`@expo/code-review-cli` includes `review-research-mcp` and exposes that one bundled,
local MCP directly to reviewer and cross-file passes. The agent decides whether an
external API contract needs research and can either search for an exact symbol or
fetch an exact supported documentation URL already present in the review context.
Coordinator, verifier, stack-verifier, and time-critical no-tools passes never receive
the MCP.
The package bundles `review-research-mcp`, a local MCP exposed only to the reviewer
and cross-file passes (never the coordinator, verifier, or no-tools passes). When a
judgment depends on an externally owned API contract, an agent can search a fixed
catalog of official documentation providers — Apple/Android platform APIs, Expo,
React Native, OkHttp, Kotlin coroutines, Gradle/AGP, Swift evolution, and more — or
fetch one exact supported documentation URL. Queries are short exact symbols
(`CameraView barcodeScannerSettings` is useful; a source snippet or a question is
not), and an empty result stays empty rather than becoming a loose guess. There is
no offline index: every passage is fetched live during that review.
Enable it only in the root config, which CI loads from the PR's trusted base:
Enable it only in the root config (CI loads it from the PR's trusted base) and add a
`BRAVE_SEARCH_API_KEY` Actions secret (Expo and OkHttp use their own official search
and consume no Brave quota):
```jsonc
{
"research": {
"enabled": true,
"maxQueries": 8,
"resultsPerQuery": 2,
"timeoutMs": 30000
}
"research": { "enabled": true, "maxQueries": 8, "resultsPerQuery": 2, "timeoutMs": 30000 }
}
```
Add a repository Actions secret named `BRAVE_SEARCH_API_KEY`. The generated
automatic and command-triggered workflows pass only that search credential to the
MCP. Expo uses its public documentation search and OkHttp uses its official static
search index; neither consumes Brave quota.
The boundary, in brief:
ECR resolves the MCP entry point inside its own installed package and starts it with
the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
either component. Each review gets an owner-only temporary MCP config and append-only
audit. Claude receives that explicit config under `--strict-mcp-config`, with project
settings and slash commands disabled; OpenCode receives the same fixed local command.
- **Bounded egress, not a confidentiality boundary.** Queries are normalized and
sanitized (credential-shaped or secret-labeled input fails closed); direct URLs
must be plain HTTPS on a fixed provider host/path allowlist; redirects, response
sizes, content types, and per-call deadlines are enforced server-side. The model
still chooses the query terms, so enable research only where repository-derived
terms may be shared with Brave and the documentation providers.
- **The server never sees a model credential.** ECR starts the MCP from its own
installed package through a wrapper that rebuilds the child environment from an
explicit allowlist; the Brave key never enters the model process either.
- **Audited and citable.** Every outbound query and returned result is audited (job
log, Actions step summary, `.runs/reviews.jsonl`). A finding can cite only URLs
actually retrieved during that review, and the verifier strips citations whose
passage does not support the claim.
- **`maxQueries` bounds MCP calls, not HTTP requests** — one search can fan out to
many discovery and page fetches, so each call reports its own request ledger and
the run reports totals.
- **Root-only in routed monorepos** (it starts a host process); scope configs
cannot alter it. Research results are fetched and audited per run; cached review
results remain keyed by the ordinary trusted review inputs and make no network call.
That command is a wrapper, not the server. Both engines merge a configured MCP `env`
onto their own environment instead of replacing it, so the declared block alone cannot
bound the child — the engine's model credential reaches it, and OpenCode additionally
passes down the runner's whole ambient environment. The wrapper therefore rebuilds the
environment from an explicit allowlist before starting the server, and loads no parser
and opens no socket of its own. The server process sees the search key, the call
bounds, and locale/proxy settings; it never sees a model credential. The Brave
credential travels the same way and is never added to the model process env.
The MCP bounds the shape, host, and volume of outbound requests. It is not a
confidentiality boundary: the reviewing model chooses the query terms and URLs, and a
low-entropy identifier can carry repository-derived data past every check below.
Enable research only where repository-derived terms may be shared with Brave and the
documentation providers. Search queries are normalized before
logging or networking: quoted literals, URLs, email addresses, paths, prose stop
words, overlong/high-entropy tokens, and unsupported punctuation are removed;
credential-shaped or secret-labeled input fails closed. The remaining query must be at
most eight short tokens and either contain an API-like symbol or be a short multi-word
lowercase concept phrase. Direct URLs must use plain
HTTPS with no credentials, port, query string, or fragment; suspicious/high-entropy
path segments fail closed. The fixed provider host/path allowlist and redirect,
response-size, content-type, and timeout checks still apply after that first gate.
These deterministic checks greatly reduce accidental exfiltration; they are not a
proof that every low-entropy string is harmless, so reviewer prompts also forbid
sending repository text and the review-wide MCP budget defaults to eight calls.
`maxQueries` bounds MCP calls, not network requests. One search selects up to four
providers, and each issues its own discovery request plus a page fetch per candidate,
so eight calls can mean roughly thirty discovery requests and over a hundred page
downloads. Every call therefore reports its own ledger — discovery requests, page
fetches, redirect hops, total HTTP requests, and elapsed time — and the review log and
Actions summary report the totals. `timeoutMs` is the MCP's own end-to-end deadline for
one call, enforced by the server across discovery, redirects, retrieval, and
extraction; a call that hits it returns what it already has rather than failing. It has
to live there because OpenCode's `timeout` bounds only tool discovery and Claude
provides no per-call timeout at all.
For non-Expo providers, discovery sends a fixed, provider-owned `site:` scope plus
the bounded query to Brave's fixed Web Search endpoint. Search snippets and titles
are never treated as evidence. ECR independently rejects off-allowlist result URLs,
manually validates every redirect, fetches a few official pages, verifies content
types and response sizes, extracts visible documentation text, and returns locally
ranked bounded passages. Sparse search-engine coverage therefore produces an honest
empty result rather than a loose guess.
Research is root-only in routed monorepos because it starts a host process; scope
configs cannot alter its network behavior or limits. Result-cache reuse remains
disabled while research is enabled because web results and documentation can change
without a config change.
The fixed provider catalog covers Apple/Android APIs plus SDWebImage, Media3, Glide, OkHttp,
Kotlin coroutines, Gradle/AGP, Swift concurrency/evolution, platform release/API
availability, Expo, React Native, Reanimated, Gesture Handler, Screens, and Worklets.
Queries are short exact symbols plus at most one useful member or behavior term. For
example, `CameraView barcodeScannerSettings` is useful; a source snippet, import path,
or natural-language question is not. The MCP publishes the same guidance in its tool
metadata. An empty result stays empty; it is not replaced with a loose semantic guess.
The tool metadata also includes an explicit provider map, so the reviewing model can
distinguish core platform APIs from release notes, dependency-owned documentation,
build-tool references, and issue-tracker context before choosing a corpus.
Reviewer instructions require grounding whenever a judgment depends on an externally
owned API contract, whether the evidence confirms a finding or dismisses a candidate
as safe; model memory alone is not treated as sufficient for those decisions.
Native source keeps its platform context: Apple or Android documents the OS contract,
while an explicit dependency provider documents library-owned behavior. Providers are
additive when both contracts matter. A path under `packages/expo-*` does not by itself
route Swift or Kotlin code to Expo's JavaScript documentation.
Direct clients can also call `fetch_platform_doc` with an exact documentation URL.
The tool infers the narrowest matching provider (or accepts an explicit provider
hint), then applies the same fixed HTTPS host/path allowlist, manual redirect checks,
10-second timeout, 5 MB response limit, content-type validation, extraction, and
passage bounds as search-discovered pages. It returns normalized extracted text, never
raw HTML or DocC JSON. An optional `query` selects context only within that one page;
it never broadens discovery. Context expands progressively:
- `focused` returns the best passage plus adjacent passages.
- `section` (the default) returns a contiguous window of at most 12,000 characters
around the best passage.
- `document` returns at most 20,000 characters of extracted page text and should be
used only when the contract is spread across the page.
The response reports returned and original character counts, whether it was truncated,
the anchor passage id, and bounded available passage ids. Search results also carry
neighboring passage ids so an agent can recognize when more local context exists. For example,
`https://developer.apple.com/documentation/swiftui/view/menustyle(_:)` is resolved to
Apple's DocC JSON and returned with the canonical page URL and API availability.
Every research-enabled review audits each sanitized outbound query and each returned result's
title, provider, provenance class, and canonical URL. GitHub Actions receives the
same audit trail in the step summary, while `.runs/reviews.jsonl` keeps the queries
plus bounded returned passages for short-lived operational inspection. Reviewers
are instructed to attach `sources` only when documentation materially supports a
finding. ECR accepts only exact URLs returned during that review, restores canonical
titles, carries citations through coordination, and renders them below the finding.
A citation to a URL this review never retrieved is dropped outright. Relatedness is a
separate, weaker guarantee: a cited finding is escalated to the verifier with the
audited passage inline, which judges whether that passage actually supports the claim
and strips the citation when it does not.
Reviewers also emit a bounded `researchDecisions` record only when documentation
materially confirms a finding candidate or proves one safe. ECR grounds those records
against the exact MCP audit and discards ungrounded claims. After verification and
suppression, the log and Actions summary report final findings with citations,
supported and dismissed candidates, and unique audited results materially used versus
unused. Counts use canonical URLs rather than passage count, so repeated hits do not
inflate usefulness.
For a query routed to the `expo` provider, `serve` POSTs the already-sanitized query
directly to Expo's public Algolia search endpoint and returns canonical
`docs.expo.dev` hits. The endpoint, application id, and browser-visible search-only
key are fixed in the package; redirects are rejected; response size, timeout, hit
count, and returned URL host are bounded.
OkHttp's newly migrated documentation is still sparse in Brave, so its provider
downloads the fixed official `lysine.dev` static search index, validates and indexes
it in memory once per MCP process, and rejects any entry outside the existing OkHttp
allowlist. Brave remains a fallback if that official index is unavailable.
There is no offline index. Every passage a review sees is fetched live from the
provider allowlist during that review, so evidence is never served from a local
artifact whose contents ECR cannot vouch for. The crawler, its seed catalog, and
`research.indexPath` were removed once live discovery replaced them; a config still
naming `indexPath` fails to parse rather than silently ignoring it.
Installation-specific provider configuration is intentionally
deferred: when added, it should follow the trusted root-config model used for agents
without permitting PR-controlled URLs, commands, or executable parsers. Expo skills
are complementary, not another search corpus: their pinned procedural guidance can
later be supplied to review agents as separately labeled trusted context, while
documentation search continues to return citable API evidence. Dynamic skills or
instructions retrieved from documentation must never become executable reviewer
instructions.
Full detail — providers, query grammar, `fetch_platform_doc` modes, provenance and
citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
## Monorepos (routing manifest)
A monorepo can route different subtrees to different reviewer rosters from a single
infra-owned manifest. There is still **one workflow, one `ecr ci` process** per PR:
it reads the changed files once, assigns each to exactly one scope, reviews each
active scope over only its files, and renders one comment. Because it is a single
writer in a single process there is no comment/check race and no locking.
it reads the changed files once, assigns each to exactly one scope (scopes are
ordered, the **last** match wins — CODEOWNERS discipline), reviews each active scope
over only its files, and renders one comment — a single writer, so no comment race
and no locking.
```
your-monorepo/
@@ -390,79 +332,56 @@ your-monorepo/
}
```
- **Path glob dialect** — `**` crosses `/`, `*` matches within a segment. Keep a
`**/*` catch-all scope so no changed file goes unreviewed (`ecr doctor` flags a
coverage gap otherwise). Scopes are ordered and the **last** match wins, so put
broad scopes first and specific ones after (CODEOWNERS/Renovate discipline).
- **Comment modes** — `single` posts one aggregated comment (a scope summary table
+ a collapsed `<details>` per scope) under the existing marker; `per-scope` posts
one cleanly-namespaced comment per scope. A scope with zero matched files gets its
stale comment deleted.
- **Scoped flags** — `ecr ci --scopes a,b` limits the fan-out; `ecr ci --comment
single|per-scope` overrides the manifest; `ecr review --scope <name>` runs one
scope locally; `ecr review`/`ecr ci --config-dir <dir>` (or `ECR_CONFIG_DIR`)
load config from an alternate directory; `ecr doctor --list-scopes` prints the
scope table. **`--config-dir` designates an alternate ROOT config dir: both
`config.jsonc` and `routing.jsonc` are read from it, so the root config and its
manifest always travel together. Scope `config` paths stay repo-root-relative —
the override swaps the root config/manifest against the *real* scope tree, it
does not relocate the scopes themselves.**
- **Passes budget** — `defaults`-level `budget` bounds total review time:
`totalPassesMinutes` (default 55) is split across active scopes (which run
sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
single scope still gets a workable window. When enough scopes are active that the
floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
doctor` flags the worst case (`scopes × floor` vs total) — raise the workflow
`timeout-minutes` or trim scopes.
- **Adoption is incremental** — with no `routing.jsonc`, behavior is exactly as
before (single config). Add the manifest with just a default scope → still one
comment, identical behavior. Land per-team scope dirs one at a time; everything
else keeps hitting the default scope.
- **Keep a `**/*` catch-all scope** so no changed file goes unreviewed (`ecr
doctor` flags a coverage gap otherwise); broad scopes first, specific ones after.
- **Comment modes** — `single` posts one aggregated comment; `per-scope` posts one
namespaced comment per scope. A scope with zero matched files gets its stale
comment deleted.
- **Passes budget** — a `defaults`-level `budget` splits `totalPassesMinutes`
(default 55) across the active scopes, floored at `minScopeMinutes` (default 5)
per scope; `ecr ci` and `ecr doctor` warn when the floor can overshoot the total.
- **Scoped flags** — `ecr ci --scopes a,b`, `ecr ci --comment single|per-scope`,
`ecr review --scope <name>`, `--config-dir <dir>` / `ECR_CONFIG_DIR` (alternate
ROOT config+manifest dir — scope `config` paths stay repo-root-relative), and
`ecr doctor --list-scopes`.
- **Adoption is incremental** — no `routing.jsonc` means exactly the old
single-config behavior; a manifest with just a default scope is identical; land
per-team scope dirs one at a time.
### Security
- **auth and research are locked to the root.** `tokenEnv` (which env var becomes the model
credential) is honored in exactly one place: the root `config.jsonc` or
`routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`/`research`
**fails to parse** (Zod-level rejection). A scope declaring `research` also fails,
so PR-controlled routing cannot select a different host index. The CI guard step independently
sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
`ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
choose *which roster* reviews a file, never *which secret* is sent.
- **enforceAgents can't be weakened.** Agents listed in `defaults.enforceAgents`
(e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
root roster — a scope defining a same-id agent gets the root one, so a team can't
shadow the enforced reviewer with a weaker version on its own subtree.
- **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
policy and reviewer configuration — `config.jsonc`, `routing.jsonc`, prompts,
models, and the auth mapping — load from the PR's immutable **base** commit,
materialized via the GitHub API. The PR head is untrusted data: it is
materialized separately (pinned to its immutable OID) purely as source content
to read and verify against. A PR editing rosters, prompts, or routing is
reviewed under the **previous** config; its changes activate after merge. If
the base commit can't be materialized, the run fails closed (one terminal
comment) — it never falls back to the checkout. A scope config that is new in
a PR is reviewed with the root config until it merges.
- **The model runtime never sees PR-owned ambient config.** The head worktree the
agents read from is scrubbed of runtime configuration before the OpenCode
server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
A PR can't install a plugin, MCP server, instruction file, or `.env` into the
process that holds the model credential and the comment token. (Changes to
those files are still reviewed — their diffs are inlined in the prompt — but a
finding citing one can't be re-read during verification; that's the tradeoff.)
- **The scaffolded workflows check out only the base commit** with
`persist-credentials: false`; the CLI's own git fetches authenticate through
`gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
CLI enforces the trust model itself, so a custom workflow that checks out the
PR head still gets base-commit configuration. The temporary escape hatch
`ecr ci --unsafe-config-from-head` restores the old behavior with a loud
security warning and will be removed on a minor boundary.
Enforced in code and by an independent CI guard step, not by convention:
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
(the single authoritative router) and `/apps/api/.expo-code-review/ @your-api-team`
(each team owns only its own scope dir). Rerouting globs is gated behind infra review.
- **auth and research are locked to the root.** `tokenEnv` is honored only in the
root `config.jsonc` / `routing.jsonc` `defaults.auth`; a scope config declaring
`auth`/`breakGlass`/`research` fails to parse, and the CI guard sweeps every
config file repo-wide and refuses to run unless `tokenEnv` appears exactly once,
root-owned, equal to `ECR_EXPECTED_TOKEN_ENV`. Routing globs choose *which
roster* reviews a file, never *which secret* is sent.
- **enforceAgents can't be weakened.** Enforced agents (e.g. `security`) are
injected into every scope from the ROOT roster with `alwaysRun`; a scope defining
a same-id agent gets the root one.
- **Configuration comes from the PR's trusted base commit.** In `ecr ci`, all
review configuration loads from the PR's immutable base; the head is untrusted
content, materialized separately only to read and verify against. A PR editing
rosters, prompts, or routing is reviewed under the **previous** config, and a
missing base fails closed — never a fallback to the checkout. (Temporary escape
hatch: `ecr ci --unsafe-config-from-head`, with a loud warning; to be removed.)
- **The model runtime never sees PR-owned ambient config.** The head worktree is
scrubbed of `opencode.json{,c}`, `.opencode/`, `AGENTS.md`, `CLAUDE.md`,
`.claude/`, `.mcp.json`, `.cursor*`, and `.env*` before the engine starts, so a
PR can't install a plugin, MCP server, instruction file, or `.env` into the
process holding the model credential and comment token.
- **The scaffolded workflows check out only the base commit** with
`persist-credentials: false`; git fetches authenticate through `gh`, so the
token never lands in `.git/config` or argv. The CLI enforces the trust model
itself, so even a custom workflow that checks out the head still gets
base-commit configuration.
Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`,
`/apps/api/.expo-code-review/ @your-api-team`. Full detail:
[LLP 0006](./llp/0006-config-schema-loading-routing.explainer.md) (routing) and
[LLP 0001](./llp/0001-trust-model.principles.md) (trust model).
---
@@ -493,62 +412,42 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
feedback always run fresh.
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
and applies the provider's prompt caching automatically.
and applies the provider's prompt caching automatically. Full detail:
[LLP 0002](./llp/0002-review-engine-pipeline.explainer.md) (pipeline) and
[LLP 0005](./llp/0005-verification-fingerprints-rendering.explainer.md)
(verification and rendering).
</details>
<details>
<summary><b>Tokens, cost &amp; prompt caching</b></summary>
Every run reports what it spent and how much of it was served from the prompt
cache, in three places:
Every run reports what it spent and how much was served from the prompt cache, in
three places: one `Token usage — …` line in the job log, a per-pass table + cache
hit rate in the GitHub Actions step summary (which also preserves each run's posted
comment, since the PR comment is updated in place), and one JSON line per run in
`.expo-code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
tokens, raw per-agent findings, bounded reviewer traces, and coverage notes.
- **Job log (stderr)** — one line at the end of the run:
`Token usage — input …, output …, cache read …, cache write … (cost $…)`.
- **GitHub Actions step summary** — a per-pass table (one row per agent, plus the
cross-cutting pass, coordinator, and verifier), the run's cache hit rate, and
the exact comment that was posted. The PR comment is updated in place on every
run, so the step summary is where past runs' comments remain readable.
- **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
dropped.
Each reviewer can also return a compact trace (up to three concrete checks and two
unresolved questions). It is stored only inside the hidden base64 comment marker as
`review.reviewTrace` — never rendered — declared
`trust: "unverified-model-diagnostics"`, and capped at 6 KB so it can't crowd
visible findings out of GitHub's comment-size limit.
Each reviewer can also return a compact trace with up to three concrete checks and
two unresolved questions. The reporter stores it only inside the existing base64
`<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
render in the visible review. Agents and other machine consumers can decode that
state to see what a clean review covered. The payload declares
`trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
raw transcript or chain-of-thought, and must not be treated as a verified finding.
The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
**How the caching works.** Provider prompt caching is a *prefix match*: any byte
change in the prefix invalidates everything after it. The reviewer keeps the prefix
stable — the system prompt (`shared.md` + the agent's own `.md`) is byte-identical
for every chunk an agent reviews, while the volatile parts (diff, file lists, PR
metadata) travel after it. OpenAI caches automatically at a steep read discount;
Anthropic charges ~1.25× input to **write** and ~0.1× to **read**. Hit rate =
`cache read / (cache read + input)`; multi-chunk reviews should show a high rate,
single-chunk reviews mostly show writes.
**How the caching works.** Provider prompt caching is a *prefix match*: the
provider caches the rendered prompt up to a point, and any byte change anywhere
in that prefix invalidates everything after it. The reviewer is laid out so the
prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
byte-identical for every chunk an agent reviews, while the volatile parts (the
diff, file lists, PR metadata) travel in the user message *after* the prefix and
never touch it. OpenAI caches automatically (no write premium; cached input is
billed at a steep discount and shows up as `cache read`); Anthropic charges a
small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
it. Entries live minutes, refreshed on use — comfortably covering a run's
concurrent calls.
**Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
share of prompt tokens served from cache instead of being reprocessed at full
price. Multi-chunk reviews should show a high rate; single-chunk reviews mostly
show writes (there is nothing to re-read within the run).
**Keeping hits high:**
- Keep `shared.md` and `agents/*.md` stable. Any edit writes a new prefix — one
extra cache write per agent on the next run, then it is warm again. Never put
varying text (dates, PR numbers) into prompt files.
- Very short prompts may show `cache read 0`: prompts below the model's minimum
cacheable size (~1–4K tokens depending on the model) are silently not cached.
That is expected, not a bug.
To keep hits high: keep `shared.md` and `agents/*.md` stable (an edit costs one
cache write per agent on the next run, then it's warm again), and never put varying
text (dates, PR numbers) in prompt files. Prompts below the model's minimum
cacheable size (~1–4K tokens) show `cache read 0` — expected, not a bug.
</details>
@@ -637,60 +536,32 @@ change which model reviewed your code. Use an explicit override instead.
<details>
<summary><b>Reliability</b> — never hangs, never silently drops work</summary>
- **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
CI job's `timeout-minutes` (90).
- **The cross-file pass is elastic** — it gets whatever is left of the passes budget
rather than a fixed cap, because it's the one pass whose scope can't be traded for
convergence: halving its file set deletes exactly the coverage it exists for. Chunk
passes run alongside it under their own caps, so a long cross-file pass doesn't
starve them.
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
finishing is *wandering*, not converging; hitting the cap trips the soft landing.
The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
tool calls go to *tracing*, not fetching).
- **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
no streamed text or reasoning, no token growth) has a wedged model request, not a
hard problem. After 4 min of silence it's abandoned and retried once from a clean
session, inside the same budget — instead of spending the whole cap on a dead
request. Progress lines say how long a reply has been silent, so this is legible in
the CI log.
- **Rate limits are detected and waited out, not fought.** The reviewer watches the
OpenCode server's own log for provider 429s (hard evidence, per run). A stall
*with* recent 429 evidence is throttling, not a wedge — the pass waits in 90s
beats (without consuming its one retry) instead of re-sending its whole context
into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
account may be serving several PRs' reviews at once. Rate-limit events are
reported in the job log and the run log (`rateLimitEvents`), so throttling is a
visible fact about a run, never a mystery slowdown.
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
is asked to return the findings it already has, rather than discarding its work.
Tools are disabled for that request, so the salvage step can't resume investigating
instead of answering.
- **Subdivide-on-timeout** — a reviewer pass that times out with nothing to show has
its chunk split in half and the halves re-reviewed (recursively, down to a single
file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
skips straight to the fallback, which still sees the whole diff). Only a genuinely
un-reducible pass reports a coverage gap — and it is always reported, never silent.
- **Parse failures are retried** (same session, then once in a bounded fresh
session) — separate from the timeout path.
- **Transient API errors are retried** (bounded backoff on 429/5xx/network) —
distinct from both the timeout path (abandon) and the parse path; a one-off blip
no longer drops an entire pass.
- **Auth failures surface once, and fail fast** — `ecr` checks the configured
provider's credential at startup and stops with one clear message if it's missing
(rather than failing every pass); a credential rejected mid-run (401/403)
collapses into a single actionable coverage note pointing at
`auth.tokenEnv`/`REVIEWER_MODEL`.
- **A failed run never reads as "Approve"** — all passes fail → "could not
complete"; some fail → never a clean approve, and coverage-reduced.
- **The coordinator can't sink the run** — if consolidation fails, findings are
merged deterministically and still posted.
- **Coverage notes** — passes that timed out/failed are listed (routine noise
filtering is *not* flagged — it's expected and stays in the run log).
- **CI always gets a terminal state** — on any failure the PR gets a "didn't run"
comment, not a stuck reaction and silence.
- **Time caps everywhere** — chunk passes 15 min, coordinator 10 min, and a global
55-min passes budget that fits inside the CI job's `timeout-minutes`. The
cross-file pass is elastic (it gets whatever budget is left) because halving its
file set would delete exactly the coverage it exists for.
- **Wandering and wedged passes are cut short** — a tool-call cap catches a pass
that reads without converging; a 4-minute stall detector abandons a wedged model
request and retries once from a clean session, inside the same budget.
- **Rate limits are waited out, not fought** — provider 429s (observed in the
OpenCode server log) turn a stall into 90s waits instead of re-sends; explicit
429s retry on a slow schedule; subscription (oauth) runs default to lower
concurrency. Rate-limit events are reported in the job and run logs, so
throttling is never a mystery slowdown.
- **Soft landing, then subdivision** — at a cap, the agent is asked to return the
findings it already has (tools disabled, so it can't keep investigating). A pass
with nothing to show has its chunk split and re-reviewed recursively, down to a
no-tools fallback over the inlined diff. Only a genuinely un-reducible pass
reports a coverage gap — always reported, never silent.
- **Bounded retries per failure class** — parse failures, transient API errors
(429/5xx/network), and timeouts each have their own separate retry path; a
one-off blip never drops a whole pass.
- **Failure can't read as success** — auth problems fail fast with one actionable
message; all-passes-failed reads "could not complete"; a partial run is never a
clean pass; a failed coordinator falls back to deterministic merging; and CI
always posts a terminal comment, never silence.
Full detail: [LLP 0002](./llp/0002-review-engine-pipeline.explainer.md).
</details>
@@ -718,19 +589,19 @@ line: **comments = one-shot actions, labels = persistent configuration.**
These workflows are comment-only (they never fail the PR's checks). The engine runs
as the published package via `npx`, so no PR-controlled code is built.
Full detail: [LLP 0009](./llp/0009-adoption-templates-and-ci-workflows.guide.md)
(workflows) and [LLP 0007](./llp/0007-cli-commands-and-ci.explainer.md) (commands).
</details>
<details>
<summary><b>Author feedback (replies to findings)</b></summary>
A PR author's reply to a finding is matched to it deterministically — by quoting
the finding's title back, or by citing its short `` `id:…` `` token (only the id can
clear a finding, see below) — and recorded
in the comment's embedded state, no model involved in the matching itself. A
matched finding shows `💬 @login replied` (linked to the comment) and a visible
count above the fold; the reply's own text is never stored or rendered, only the
login, the comment link, and (optionally) an enum-valued verdict. Controlled by
the root-only `feedback` block in `config.jsonc`:
the finding's title back, or by citing its short `` `id:…` `` token — with no model
involved in the matching. A matched finding shows `💬 @login replied` (linked to
the comment); the reply's own text is never stored or rendered. Controlled by the
root-only `feedback` block in `config.jsonc`:
```jsonc
"feedback": {
@@ -743,36 +614,26 @@ the root-only `feedback` block in `config.jsonc`:
```
- **`annotate`** (the default) matches and shows "author replied" with zero effect
on the decision — safe and useful even if you never touch this block.
- **Clearing a finding always needs the `` `id:…` `` token**, in the replier's own words
(an id inside a `>` quote does not count). Quoting the title is enough to *annotate*,
never to clear: GitHub's "Quote reply" copies the PR author's text verbatim, so a
maintainer clicking it would otherwise dismiss a finding on words the author wrote —
by accident, or because they were led to.
- **`adjudicate`** additionally has a model re-check the reply against the actual
source (distrust-by-default, like the finding verifier) and record a verdict.
Whether that verdict can actually clear a finding is a separate, still-off-by-
default choice — `mode` and `dismiss` are independent axes: `dismiss:
"maintainers"` lets a maintainer's own reply dismiss with no model involved
(it works under plain `annotate` too); `dismiss: "adjudicated"` additionally
accepts an author reply the model confirmed (which does need `mode:
"adjudicate"` for verdicts to exist). Either way the reply has to cite the finding's
`` `id:…` ``. A `critical` finding, or one categorized `secrets`/
`security`, can never be cleared this way, whatever the config — that floor is
enforced in code, not the prompt.
- **`/undismiss <id>` wins over a reply.** Running it on a finding a reply cleared
puts the finding back in the active list and keeps it there: another reply from
the PR author can't clear it again. The restore is recorded against the FINDING
in the comment state, not against the reply, so editing or deleting the reply
doesn't drop it either. Only a maintainer lifts that — either `/dismiss <id>` on
the same finding, or a maintainer's own reply to it.
on the decision — safe even if you never touch this block.
- **Clearing a finding always needs the `` `id:…` `` token** in the replier's own
words (an id inside a `>` quote does not count). Quoting the title *annotates*,
never clears — otherwise GitHub's "Quote reply" could dismiss a finding on words
the PR author wrote.
- **`mode` and `dismiss` are independent axes.** `adjudicate` has a model re-check
the reply against the actual source and record a verdict; `dismiss:
"maintainers"` lets a maintainer's own reply dismiss with no model involved;
`dismiss: "adjudicated"` additionally accepts an author reply the model
confirmed. A `critical`, `secrets`, or `security` finding can never be cleared
this way, whatever the config — that floor is enforced in code, not the prompt.
- **`/undismiss <id>` wins over a reply** — it restores the finding and pins the
restore to the FINDING, so another author reply (or editing/deleting the old
one) can't clear it again; only a maintainer lifts that.
`ecr feedback` mines this substrate retroactively, with no model call and no
re-review: it crawls a repo's PRs, reads each one's existing reviewer comment
(which already embeds its findings), matches non-bot replies against it, and
reports totals, a reply-rate, breakdowns by category/severity/agent, and — the
highest-value part — "repeat offenders": findings whose title recurred across 2+
PRs and drew a reply every single time.
re-review: it crawls a repo's PRs, matches non-bot replies against each existing
reviewer comment, and reports totals, a reply-rate, breakdowns by
category/severity/agent, and "repeat offenders" — findings whose title recurred
across 2+ PRs and drew a reply every time.
```bash
ecr feedback --repo your-org/your-repo --limit 100 --since 2026-06-01
@@ -780,19 +641,51 @@ ecr feedback --as my-review-bot # if CI posts under a PAT/app identity
ecr feedback --json # for scripting
```
The crawl matches the reviewer's comments by author. CI posts them as
`github-actions[bot]` (the default), so a locally-run crawl uses that identity —
pass `--as <login>` when your workflow posts under something else.
The crawl matches the reviewer's comments by author (`github-actions[bot]` by
default — pass `--as <login>` when your workflow posts under something else), and
always reads config from the LOCAL checkout, warning when `commentTag` may not
match a different `--repo`.
`ecr feedback` always reads `.expo-code-review/config.jsonc` from the LOCAL
checkout, even with `--repo`. If `--repo` points at a different repo, it warns
that `commentTag` may not match, so a zero-findings result there is not read as
zero pushback. It also warns when every scanned PR had no bot comment at all,
instead of leaving that as an easy-to-miss "0 with a bot comment" in the totals.
Full detail — why matching is deterministic, why reply text is never echoed, why
the defaults are asymmetric: [LLP 0011](./llp/0011-author-feedback.explainer.md).
See [LLP 0011](./llp/0011-author-feedback.explainer.md) for why matching is
deterministic, why reply text is never echoed into the comment, and why the
defaults are asymmetric.
</details>
<details>
<summary><b>Inline PR comments (findings on the diff line)</b></summary>
Off by default. When enabled, each finding anchored to a line that is actually in
the PR's diff is posted as an inline review comment on that line, carrying the
full rationale, sources, and suggestion. The main comment still lists every
finding, but an inlined one renders short — title, links, category, id, and a
one-line rationale — plus a `💬 inline comment` link to the thread. The main
comment remains the single durable state store: `/dismiss`, embedded state, and
reply adjudication are unchanged.
```jsonc
"inline": {
"enabled": false,
"maxComments": 20 // per PR (single comment mode) / per scope (per-scope mode)
}
```
Behavior notes:
- **Threads converge, never churn.** Each inline comment carries a per-finding
marker and is patched in place across re-reviews. The cap is sticky: a live
thread is never evicted to create a new one. A finding that leaves the set is
stubbed if humans replied in its thread (never deleted), deleted only when bare.
- **Fail-safe teardown.** A failed run, a `--scopes` partial run, or a truncated
aggregate never tears threads down — teardown only runs when the target set is
provably complete. Creates are skipped (and logged) when the PR head moved
during the review.
- **Replies on inline threads count as author feedback.** Replying in a thread
matches the reply to that finding structurally; clearing still requires the
`id:…` token in the replier's own words, same as main-comment replies.
- **Notification fan-out.** The first enabled run on a PR sends one notification
per created inline comment; `maxComments` bounds it.
- **Disabling later** leaves existing threads up (the sync no longer runs); a
comment-mode switch — or the reporter's clear path — stubs/removes them.
</details>
@@ -845,39 +738,54 @@ head commit, or local comment-policy fingerprint no longer matches.
<details>
<summary><b>Other providers & auth modes</b></summary>
The scaffolded default is **Anthropic via the Claude Code CLI** — see First-time
setup above and the Anthropic bullet below for how it authenticates and is
sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
credentials through OpenCode.
Everything is set in `config.auth`. Engines are inferred **per agent** from that
agent's resolved model: an `anthropic/…` agent runs through the Claude Code CLI,
any other provider runs through OpenCode — in the SAME run. `REVIEWER_MODEL`
overrides every agent's model (and therefore engine) at once. There is no shared
fallback key; `ecr doctor` diagnoses setup.
- **Anthropic / Claude (the scaffolded default).** The credential is (in order) a
`tokenEnv` you name, an ambient `CLAUDE_CODE_OAUTH_TOKEN`, or your local
`claude` login — an `auth` entry is entirely optional. `claude setup-token`
mints a Max/Team subscription token for CI, or point `tokenEnv` at a Console API
key (`sk-ant-api…`); the CLI reads either. Each pass is trust-isolated and
read-only: `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
`--strict-mcp-config`, only `Read`/`Grep`/`Glob`, and an allowlisted child env
that omits ambient Anthropic credentials.
- **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
anthropic pass runs through the **Claude Code CLI** (`claude -p
--output-format stream-json --verbose`), inferred from the model. The
credential is (in order) a `tokenEnv` you name, an ambient
`CLAUDE_CODE_OAUTH_TOKEN`, or your local
`claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
`--strict-mcp-config`, `--permission-mode dontAsk`, and only the
`Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
(only the configured credential is re-injected).
```jsonc
// The scaffolded default. No anthropic entry at all falls back to your
// `claude` login.
// The scaffolded default. No anthropic entry at all falls back to `claude` login.
"auth": { "providers": {
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
} }
```
- **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
recommended mix if you review with OpenAI.** OpenAI permits subscription auth
in third-party tools, and OpenCode
ships the plugin for it — so the reviewer runs its default models on the
subscription (zero marginal cost) and reserves the metered key for pro-tier
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
Use the per-provider map form:
- **Meta / Muse Spark 1.2 (public Model API).** Set the default model and any
model-pinned agent frontmatter to `meta/muse-spark-1.2`. ECR supplies the fixed
public Responses endpoint and the `@ai-sdk/openai` adapter; the generic
Chat-Completions compatibility adapter is intentionally not used because it
loses Muse's reasoning continuity across tool turns. Standard and Contributor
model ids are supported.
```jsonc
"model": "meta/muse-spark-1.2",
"auth": { "providers": {
"meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" }
} }
```
Create the key in the [Meta AI developer portal](https://developer.meta.com/ai/).
Locally, export it as `META_API_KEY`. In each review-running workflow, replace
the Anthropic credential line with
`META_API_KEY: ${{ secrets.META_API_KEY }}`, set the repo variable
`ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, and remove the Claude CLI install step
if no configured agent uses an `anthropic/…` model. Muse runs with high reasoning.
- **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key** — the
recommended mix if you review with OpenAI. Default models run on the
subscription (zero marginal cost); a metered key covers subscription-excluded
pro models via a synthesized `openai-api` alias (agents reference
`openai-api/gpt-5.5-pro` in frontmatter):
```jsonc
"auth": { "providers": {
@@ -886,63 +794,26 @@ credentials through OpenCode.
} }
```
`openai-api` is an alias the reviewer synthesizes in the OpenCode config
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
The oauth `tokenEnv` holds the ACCESS token from an `opencode auth login`
ChatGPT sign-in (`ecr setup-auth` extracts it) — never the single-use refresh
token. Access tokens expire (~10 days observed), so CI secrets need periodic
re-minting; `doctor` and the run preflight warn before expiry. The API key
needs only *Responses → Request* and *Chat completions → Request*, in a
dedicated budget-capped project. In CI, set `ECR_EXPECTED_TOKEN_ENV` to both
env names, comma-separated. Every pass logs which provider/model answered it,
so the subscription/API split is visible per run (alias-model passes report
`$0` cost — OpenCode can't price config-declared aliases; the OpenAI dashboard
is the source of truth for spend).
- **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
ChatGPT sign-in (`ecr setup-auth` extracts it) — a plain bearer, valid for
days, with no rotation involvement. Do **not** use the refresh token as a
shared secret: refresh tokens are single-use (rotation), so a static copy is
spent by its first use and the sign-in dies with it. Access tokens expire
(~10 days observed), so CI secrets need periodic re-minting — see the
token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
preflight warn before expiry.
- **The API key needs exactly two permissions** — a *Restricted* key with
*Model capabilities*: **Responses → Request** and **Chat completions →
Request**; everything else (including *List models*) stays None. Create it
in a dedicated, budget-capped project. (`ecr setup-auth` prints these
instructions too.)
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
comma-separated set of both env names
(`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
workflow.
- **Auditability**: every pass logs which provider/model answered it (job log,
step summary, run log), so the subscription/API split is visible per run.
One caveat: OpenCode can't price alias models (they're config-declared), so
pro passes report `$0` in the run log's cost column — token counts are
correct, and the OpenAI project dashboard is the source of truth for spend.
- **Another provider** — the current path is the `REVIEWER_MODEL`
env override: `opencode auth login` once (pick the provider), then run with
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
and uses your OpenCode login, so no `auth` block is needed.
- **Another provider** — `opencode auth login` once, then run with e.g.
`REVIEWER_MODEL=google/gemini-3-pro`. No `auth` block needed.
Engines are inferred **per agent** from that agent's resolved model alone: an
`anthropic/…` agent runs through the Claude Code CLI while other agents run through
OpenCode — in the SAME run. So an anthropic model may coexist with an `openai` (or
any other) OpenCode provider, and each agent's `model` selects its engine.
`REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
engine), converging the whole run onto one engine.
Setup errors fail fast, with the fix in the message, instead of failing every pass
identically: the credential's shape is checked by name before any pass runs,
configured OpenCode model ids are validated against the running server (with close
matches suggested; `anthropic/…` ids are validated per-request by Claude instead),
and `ecr doctor` reports the `opencode` version actually in use.
There is no shared fallback key; if a run fails for lack of credentials, log in
with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
diagnoses setup.
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
would otherwise fail every pass identically — a run that spends its whole budget
rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
- **The credential's shape is checked.** OpenCode refuses a malformed credential by
dropping the provider entirely, which then surfaces as "model not found" for every
model, with nothing pointing at the credential. A truncated value, surrounding
whitespace, or a token that can't work for the configured `auth.mode` is rejected
by name.
- **Configured model ids for OpenCode-routed providers are checked against the running
server**, so a typo or an id the provider doesn't have is reported once, up front,
with the close matches. `anthropic/…` (Claude Code) model ids aren't checked up
front — Claude validates them per-request, so a typo there surfaces as a per-pass
error instead.
- **`ecr doctor` reports the `opencode` version actually in use** and warns when a
different one is first on your `PATH` — runs use the version this package pins.
Full detail: [LLP 0003](./llp/0003-model-runtimes-and-credentials.explainer.md).
</details>
+2 -2
View File
@@ -222,7 +222,7 @@ PRs** (do these first, in this order):
`gh pr checkout`s the PR head; it builds/runs only the trusted base ref. See
"Reliability & security" below for the residual work.
- **Failure-path hardening (audit 2026-07-22)** — a failed/timed-out run never
renders as a clean "Approve"; the coordinator has its own 5-min cap +
renders as a clean "Ready for human review"; the coordinator has its own 5-min cap +
soft-landing and a deterministic local-merge fallback (a coordinator hiccup no
longer discards all findings); CI always posts a terminal state on failure;
coverage notes now include filtered (binary/generated/ignored) files; the
@@ -342,7 +342,7 @@ Guarantees (priority order; ✅ = shipped):
4. ✅ **Always post a result** — a failed run reports "could not complete"; CI
posts a terminal state on any failure; the coordinator has a deterministic
fallback so its failure can't discard findings; and a failed/timed-out run
never renders as a clean "Approve".
never renders as a clean "Ready for human review".
5. ✅ **Global time budget.** *(Shipped 2026-07-22; retuned 2026-07-26.)*
`PASSES_BUDGET_MS` (55m) is a hard wall-clock ceiling for all passes incl.
subdivision/fallback waves: past it, a timed-out pass is reported as a gap rather
+1 -1
View File
@@ -37,7 +37,7 @@ These hold repo-wide. Each is owned in detail by one doc; break any of them and
1. **Trusted base-commit config.** Review policy and executable reviewer config (`config.jsonc`, `routing.jsonc`, prompts, models, auth/`tokenEnv` mapping) load only from the PR's immutable base commit; the PR head is untrusted data used only for diff/source content and finding verification [observed: commit `601b19a` "Trusted base-commit configuration by default; isolate the runtime from PR-head config"; PLAN-trusted-base-config.md]. Owned by [LLP 0001](0001-trust-model.principles.md) / [LLP 0006](0006-config-schema-loading-routing.explainer.md).
2. **Single-source `tokenEnv`.** `auth.tokenEnv` is honored in exactly one place (root config or `routing.jsonc` `defaults.auth`), enforced twice: schema-level (scope configs reject `auth`/`breakGlass`) and CI-guard level (`verify-config` sweeps every config file) [observed: AGENTS.md:51-53; src/core/auth.ts:45,206 `FORBIDDEN_TOKEN_ENVS`]. Owned by [LLP 0006](0006-config-schema-loading-routing.explainer.md).
3. **A failed run never reads as Approve, and `ecr ci` never fails checks.** A failed, partial, or timed-out run must never render as a clean "Approve"; `ci` is comment-only and non-blocking [observed: src/commands/ci.ts:60 "non-blocking (a reviewer failure never fails the PR's checks)"; AGENTS.md:50]. Owned by [LLP 0007](0007-cli-commands-and-ci.explainer.md).
3. **A failed run never reads as Approve, and `ecr ci` never fails checks.** A failed, partial, or timed-out run must never render as a clean "Ready for human review"; `ci` is comment-only and non-blocking [observed: src/commands/ci.ts:60 "non-blocking (a reviewer failure never fails the PR's checks)"; AGENTS.md:50]. Owned by [LLP 0007](0007-cli-commands-and-ci.explainer.md).
4. **Critical/secrets findings are never silently suppressed.** A critical- or secrets-severity finding can never be silently dropped by any mechanism except the explicit `/skip-review` marker; all other suppression is a display filter, never a review skip [observed: src/core/suppress.ts:21-23,39-43 (critical/secrets findings are never dropped by the inline-ignore backstop)]. Owned by [LLP 0005](0005-verification-fingerprints-rendering.explainer.md).
5. **Read-only agents.** Reviewer agents run with read-only tools only (read/grep/glob/list for OpenCode; Read/Grep/Glob for Claude Code passes) — never Bash/Edit/Write/WebFetch/WebSearch [observed: src/core/claude-code.ts:80-85 `READ_TOOL_MAP`/`ALL_READ_TOOLS`]. Owned by [LLP 0003](0003-model-runtimes-and-credentials.explainer.md).
6. **Stateless runs.** Prior comments, findings, and dismissal state are never fed back into review agents; every run is stateless over (diff + repo + PR title/body) [observed: src/core/prompts.ts:132-174 (the reviewer task is built only from the current diff, not prior findings); src/commands/dismiss.ts:1 (dismissal is a display-only mutation that never re-runs the review); src/core/render.ts:144]. Owned by [LLP 0002](0002-review-engine-pipeline.explainer.md).
+1 -1
View File
@@ -59,7 +59,7 @@ The degraded paths keep a failed run honest:
- **Coordinator failure never discards findings.** If `coordinate()` throws, `fallbackConsolidation()` merges and dedupes the agents' findings by fingerprint and picks a conservative decision — `request_changes` on any critical, else `approve_with_comments`, never a clean approve when findings exist [observed] (`review.ts:800-809`, `978-1009`).
- **`applyReviewPolicy` runs identically on both paths** — the coordinator's normal output and the local fallback both pass through the same policy (drop suggestions unless opted in, sort by severity, cap by `maxFindings`, downgrade `approve_with_comments`→`approve` when nothing remains), so enforcement can't diverge between the happy and degraded paths [observed] (`review.ts:792` and `998-1008`).
- **Any failed/timed-out pass forces the decision off a clean approve** — downgraded to `approve_with_comments` at minimum, independent of what the coordinator decided [observed] (`review.ts:810-815`).
- **An all-failed run sets `couldNotComplete`.** When every pass fails or times out, the run is never rendered as a normal decision string; the flag overrides presentation so the comment header can't read "Approve with comments" over a review that reviewed nothing [observed] (`review.ts:764-777`; commit `188f852`, euxy#8).
- **An all-failed run sets `couldNotComplete`.** When every pass fails or times out, the run is never rendered as a normal decision string; the flag overrides presentation so the comment header can't read "Ready for human review (with comments)" over a review that reviewed nothing [observed] (`review.ts:764-777`; commit `188f852`, euxy#8).
## Post-Coordination Order
@@ -96,6 +96,26 @@ POSIX, where `execvp` never searches cwd. This is a knowingly accepted residual,
oversight, and must not be silently reintroduced as a "fix" ([observed]
`opencode.ts:305-314`).
### Muse Spark via Meta Model API
Muse Spark uses the existing OpenCode engine, but OpenCode `1.18.4` does not ship a
resolvable `meta` provider. `buildOpencodeConfig` therefore synthesizes one only for an
in-use `meta` API-key entry without an `upstream`; an entry with `upstream` remains an
alias and never redirects that upstream's credential to Meta. The native provider's
destination is fixed to `https://api.meta.ai/v1`, its credential is an environment
interpolation (never the key value), and its adapter is `@ai-sdk/openai` so requests use
the Responses API and preserve encrypted reasoning across tool turns. The generic
`@ai-sdk/openai-compatible` adapter is deliberately not used because its Chat
Completions path loses that continuity [observed:
`opencode.ts` `META_MODEL_API_BASE_URL` / `META_MUSE_MODELS` / `buildOpencodeConfig`].
The declared model catalog is an allowlist (`muse-spark-1.2` and
`muse-spark-1.2-contributor`), not a reflection of arbitrary config strings. This keeps
the existing preflight honest: a misspelled or not-yet-supported Meta model remains
absent from the server's provider model list and fails once before any pass. Both models
declare their public context/output limits, multimodal inputs, high reasoning, and
encrypted reasoning inclusion [observed: `opencode.ts` `META_MUSE_MODELS`].
## Claude Code CLI Containment
The Claude Code engine runs each review pass as a single stateless
@@ -137,6 +137,7 @@ doctor resolves auth the way real reviews do: a `routing.jsonc` `defaults.auth`
- It hands out only ACCESS tokens; the refresh token never leaves OpenCode's store, because OpenCode's refresh tokens are SINGLE-USE (rotation) and OpenCode is their sole legitimate consumer — a copy in a shell config or CI secret dies on the next rotation and can take the whole sign-in down with it [observed: `src/commands/setup-auth.ts:87-93`].
- The exported shell line single-quotes the token value so a token containing shell metacharacters (`$HOME`, `!`) is never expanded when sourced [observed: `src/commands/setup-auth.ts:127-131`; test in `src/__tests__/setup-auth.test.ts`].
- `anthropic` auth is always satisfied by the Claude Code CLI subscription-login flow regardless of the declared `mode` (oauth vs api-key) — mode is irrelevant for that provider [observed: `src/commands/setup-auth.ts:50-59`].
- A `meta` API-key entry is a manual-key plan: setup-auth points to the Meta AI developer portal and prints the configured `META_API_KEY` export, while doctor uses the same generic provider readiness check as the real review [observed: `src/commands/setup-auth.ts` `planFromAuth` / manual-key guidance; `src/core/auth.ts` `PROVIDER_KEY_ENV`].
- The two interactive logins run via `spawnSync` with `stdio: "inherit"` and `cwd: os.tmpdir()` — the tmpdir cwd keeps a possibly-untrusted checkout from becoming the login subprocess's working directory, and the `inherit` stdio is why these two calls cannot be converted to `exec.ts`'s capturing `run()` [observed: `src/commands/setup-auth.ts:200-203,256-259`].
## init and dismiss
@@ -55,6 +55,15 @@ The three workflows share a posture built entirely around one fact: a reviewer j
**Concurrency defaults by auth mode.** The commented `chunk.concurrency` default is 6 for an API key and 3 for a subscription (OAuth) credential, "one account handles many parallel streams poorly, and several PRs may review on the same credential at once" [observed: `templates/config.jsonc:28-30`].
**Muse Spark is opt-in without weakening the default secret surface.** The root config
documents `meta/muse-spark-1.2` plus a `META_API_KEY` auth entry, and each
review-running workflow shows the corresponding secret line commented out. It is not
forwarded by the default scaffold: adopters replace the Anthropic line and set
`ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, so the model process receives only the
credential actually selected by trusted config. The default remains Anthropic and
`dismiss.yml` still receives no model credential [observed: `templates/config.jsonc`;
`templates/workflow.yml`, `command.yml`, and `atlantis.yml`].
**Keep the `**/*` catch-all first in `routing.jsonc`.** Scopes match last-match-wins, so a `**/*` catch-all must come first and more specific scopes come after to override it [observed: `templates/routing.jsonc:27-31`; `AGENTS.md:17-18`]. Nothing validates the order — reversing it silently changes which config applies to already-routed files [inferred: no code checks that the `**/*` catch-all comes first, so reversing it silently changes routing].
**`scope-config.jsonc` deliberately omits `auth` and `commentTag`.** It is a *different* template from the root `config.jsonc`, not a copy. A scope config carries no `auth` block — credentials are root-only, and the loader plus the CI guard reject a scope config that declares one [observed: `templates/scope-config.jsonc:1-3`; `AGENTS.md:51`]. It also omits `commentTag`, because a scope's PR-comment marker is always derived as `<rootTag>:<scope-name>` so `ecr ci` and `ecr review --scope --post` target the same comment; declaring one is rejected by the scope schema [observed: `templates/scope-config.jsonc:23-25`]. Reusing the root `config.jsonc` template for a scope — a tempting "simplification" — would break the trust boundary (LLP 0006).
+10 -1
View File
@@ -228,7 +228,16 @@ the queries and bounded results.
The built-in source catalog covers Apple, Android, platform releases, Swift
Evolution, SDWebImage, Media3, Glide, OkHttp, Kotlin coroutines, Gradle/AGP, selected JetBrains
issues, Expo, React Native, Reanimated, Gesture Handler, Screens, and Worklets.
issues, Expo, React, React Native, WHATWG and W3C web specifications, Chrome DevTools
Protocol, Flow, TypeScript, the Android NDK, CMake, CocoaPods, Metro, Reanimated,
Gesture Handler, Screens, and Worklets. The React Native
additions cover the contracts its implementation must preserve: React semantics, its
web-compatible APIs, the repository's type systems, and its native build and dependency
managers. Hermes documentation is intentionally not fetched from the engine's `main`
branch because it can disagree with React Native's vendored revision. Repository PR and
issue history is intentionally not a
live evidence provider: it belongs in a versioned offline evaluation corpus used to
improve prompts, while the runtime MCP remains an API-contract channel.
The canonical OkHttp host is backed by both the
[maintainer's transfer announcement](https://jakewharton.com/the-lysine-contingency/)
and [Commonhaus's project-transfer record](https://www.commonhaus.org/activity/315.html).
+88
View File
@@ -141,6 +141,94 @@ const cases = [
title: "FlatList",
body: "getItemLayout is an optional optimization for FlatList. It lets the list skip the measurement of dynamic content when the row height is known ahead of time.",
},
{
label: "react",
provider: "react",
platform: "react-native",
providers: ["react"],
query: "useEffect dependency array",
expected: "react.dev/reference/react/useEffect",
url: "https://react.dev/reference/react/useEffect",
title: "useEffect",
body: "useEffect synchronizes a component with an external system. Every reactive value read by the effect belongs in its dependency array.",
},
{
label: "flow",
provider: "flow",
platform: "react-native",
providers: ["flow"],
query: "exact object types",
expected: "flow.org/en/docs/types/objects",
url: "https://flow.org/en/docs/types/objects/",
title: "Object Types",
body: "An exact object type rejects extra properties. Flow object types are exact by default unless they are explicitly declared inexact.",
},
{
label: "typescript",
provider: "typescript",
platform: "react-native",
providers: ["typescript"],
query: "declaration file module exports",
expected: "typescriptlang.org/docs/handbook/declaration-files",
url: "https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-d-ts.html",
title: "Modules .d.ts",
body: "A module declaration file describes the runtime exports of a JavaScript module. Its export shape must match the values consumers can import.",
},
{
label: "web-standards",
provider: "web-standards",
platform: "react-native",
providers: ["web-standards"],
sourceKind: "standard",
query: "AbortSignal fetch cancellation",
expected: "fetch.spec.whatwg.org",
url: "https://fetch.spec.whatwg.org/",
title: "Fetch Standard",
body: "An AbortSignal can abort a fetch operation. The fetch promise rejects when the request is terminated by the signal.",
},
{
label: "chrome-devtools-protocol",
provider: "chrome-devtools-protocol",
platform: "react-native",
providers: ["chrome-devtools-protocol"],
sourceKind: "standard",
query: "Runtime evaluate awaitPromise",
expected: "chromedevtools.github.io/devtools-protocol/tot/Runtime",
url: "https://chromedevtools.github.io/devtools-protocol/tot/Runtime/",
title: "Runtime domain",
body: "Runtime.evaluate evaluates an expression on the inspected page. The awaitPromise option waits for a returned promise and reports its resolved result.",
},
{
label: "android-ndk",
provider: "android-ndk",
providers: ["android-ndk"],
query: "JNIEnv local references",
expected: "developer.android.com/ndk/guides/jni-tips",
url: "https://developer.android.com/ndk/guides/jni-tips",
title: "JNI tips",
body: "JNI local references are valid only for the duration of the current native method call and on the current thread. Long-lived references must be promoted to global references.",
},
{
label: "cmake",
provider: "cmake",
providers: ["cmake"],
query: "target_link_libraries transitive",
expected: "cmake.org/cmake/help/latest/command/target_link_libraries",
url: "https://cmake.org/cmake/help/latest/command/target_link_libraries.html",
title: "target_link_libraries",
body: "target_link_libraries links a target to libraries and can publish transitive link dependencies through the target's interface.",
},
{
label: "cocoapods",
provider: "cocoapods",
platform: "apple",
providers: ["cocoapods"],
query: "Podfile use_frameworks linkage",
expected: "guides.cocoapods.org/syntax/podfile",
url: "https://guides.cocoapods.org/syntax/podfile.html",
title: "Podfile Syntax Reference",
body: "The use_frameworks directive configures pods to build as frameworks and accepts a linkage option for static or dynamic frameworks.",
},
{
label: "react-native-reanimated",
provider: "react-native-reanimated",
+19
View File
@@ -270,6 +270,25 @@ test("checkProviderAuth: an entry for an IN-USE provider is still enforced", ()
expect(r.detail).toContain("openai");
});
test("checkProviderAuth: Meta uses META_API_KEY and rejects cross-provider reuse", () => {
const meta = cfgWithModels(["meta/muse-spark-1.2"], {
mode: "api-key",
provider: "meta",
tokenEnv: "META_API_KEY",
});
expect(checkProviderAuth(meta, {}).detail).toContain("META_API_KEY");
expect(checkProviderAuth(meta, { META_API_KEY: "meta-review-key" }).ok).toBe(true);
const wrongOwner = cfgWithModels(["openai/gpt-5.5"], {
mode: "api-key",
provider: "openai",
tokenEnv: "META_API_KEY",
});
const result = checkProviderAuth(wrongOwner, { META_API_KEY: "meta-review-key" });
expect(result.ok).toBe(false);
expect(result.detail).toContain("meta's well-known credential env");
});
test("prepareAuth: does not forward a dead unused-provider api-key into its key env", async () => {
// An entry for a provider no model uses must not have its tokenEnv copied into the
// provider key env — checkProviderAuth skips its guard, so forwarding would
+46
View File
@@ -285,6 +285,52 @@ test("parseClaudeResult: success maps text/cost/tokens/model", () => {
expect(r.structuredOutputFailure).toBe(false);
});
test("parseClaudeResult: records thinking tokens as reasoning, without inflating output", () => {
const stdout = JSON.stringify({
type: "result",
subtype: "success",
is_error: false,
result: "the findings",
total_cost_usd: 0.5,
usage: {
input_tokens: 10,
output_tokens: 200,
output_tokens_details: { thinking_tokens: 170 },
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
});
const r = parseClaudeResult(stdout);
expect(r.tokens.reasoning).toBe(170);
expect(r.tokens.output).toBe(200);
});
test("parseClaudeResult: omits reasoning when the CLI reports no breakdown", () => {
const stdout = JSON.stringify({
type: "result",
subtype: "success",
is_error: false,
result: "the findings",
total_cost_usd: 0.5,
usage: { input_tokens: 10, output_tokens: 200 },
});
expect(parseClaudeResult(stdout).tokens.reasoning).toBeUndefined();
});
test("parseClaudeResult: survives a null/!object output_tokens_details", () => {
for (const details of [null, "nope", 7]) {
const stdout = JSON.stringify({
type: "result",
subtype: "success",
is_error: false,
result: "x",
total_cost_usd: 0,
usage: { input_tokens: 1, output_tokens: 2, output_tokens_details: details },
});
expect(parseClaudeResult(stdout).tokens.reasoning).toBeUndefined();
}
});
test("parseClaudeResult: reads the final result from stream-json JSONL", () => {
const stdout = [
JSON.stringify({ type: "system", subtype: "init", model: "claude-opus-5" }),
+369
View File
@@ -0,0 +1,369 @@
import { test, expect } from "bun:test";
import {
activeFindings,
hasUnquotedMarker,
inlineBodiesEqual,
planInlineSync,
} from "../reporters/github.js";
import type { ExistingInlineComment } from "../reporters/github.js";
import {
inlineCommentMarker,
inlineStubBody,
oneLineRationale,
parseInlineMarkerFp,
renderInlineCommentBody,
renderMarkdown,
} from "../core/render.js";
import type { LinkContext } from "../core/render.js";
import { hasUnquotedInlineMarker, matchReplies, replyIsNewer } from "../core/responses.js";
import type { ReplyComment } from "../core/responses.js";
import { applyPins, fingerprintFinding } from "../core/schema.js";
import type { CoordinatorOutput, FeedbackRecord, Finding } from "../core/schema.js";
const finding = (over: Partial<Finding> = {}): Finding => ({
severity: "warning",
category: "quality",
file: "a.ts",
line: 5,
title: "Observer restarts for cached instances",
rationale: "getInstances() restarts the push token observer for cached instances.",
...over,
});
const existing = (over: Partial<ExistingInlineComment> = {}): ExistingInlineComment => ({
id: 1,
fp: "aaaa11",
body: "old",
hasReplies: false,
...over,
});
const TAG = "expo-ai-code-reviewer";
// ---- marker format + anchored parse ----
test("inline marker roundtrips through the anchored parser", () => {
const body = renderInlineCommentBody(finding(), TAG, "deadbeef11");
expect(body.startsWith(inlineCommentMarker(TAG, "deadbeef11"))).toBe(true);
expect(parseInlineMarkerFp(body, TAG)).toBe("deadbeef11");
expect(parseInlineMarkerFp(inlineStubBody(TAG, "deadbeef11"), TAG)).toBe("deadbeef11");
});
test("parseInlineMarkerFp: a marker anywhere but byte 0 parses to nothing", () => {
const forged = `prose first\n${inlineCommentMarker(TAG, "deadbeef11")}`;
expect(parseInlineMarkerFp(forged, TAG)).toBeNull();
// A quote-reply carrying the marker behind `> ` is not identity either.
expect(parseInlineMarkerFp(`> ${inlineCommentMarker(TAG, "deadbeef11")}`, TAG)).toBeNull();
});
test("parseInlineMarkerFp: constrains the fp to the fingerprint alphabet and the exact tag", () => {
expect(parseInlineMarkerFp(`<!-- ${TAG}:inline:fp=NOTHEX -->`, TAG)).toBeNull();
expect(parseInlineMarkerFp(`<!-- other-tag:inline:fp=deadbeef11 -->`, TAG)).toBeNull();
// A scoped tag never cross-parses under the root tag.
expect(parseInlineMarkerFp(`<!-- ${TAG}:scope:inline:fp=deadbeef11 -->`, TAG)).toBeNull();
});
// ---- inline body ----
test("renderInlineCommentBody: id token, severity, rationale, suggestion, and no state markers", () => {
const body = renderInlineCommentBody(
finding({ suggestion: "cache it", rationale: "watch <!-- evil --> out" }),
TAG,
"deadbeef11",
);
expect(body).toContain("`id:deadbeef11`");
expect(body).toContain("🟡 Warning");
expect(body).toContain("**Suggestion:** cache it");
// Untrusted text can never smuggle a raw HTML comment opener into our body.
expect(body).toContain("&lt;!-- evil -->");
expect(body).not.toMatch(/:(?:state|fingerprints)=/);
});
test("inlineStubBody: neutral — never claims resolved or dismissed", () => {
const stub = inlineStubBody(TAG, "deadbeef11");
expect(stub.toLowerCase()).not.toContain("resolved");
expect(stub.toLowerCase()).not.toContain("dismissed");
expect(stub).toContain("main review comment");
});
// ---- oneLineRationale ----
test("oneLineRationale: collapses whitespace, escapes <, truncates at a word boundary", () => {
expect(oneLineRationale("a\n\nb c")).toBe("a b c");
expect(oneLineRationale("<details>x</details>")).toBe("&lt;details>x&lt;/details>");
const long = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" ");
const short = oneLineRationale(long);
expect(short.length).toBeLessThanOrEqual(161);
expect(short.endsWith("…")).toBe(true);
// Cut lands on a word boundary: the kept prefix is followed by a space in the input.
const kept = short.slice(0, -1);
expect(long.startsWith(kept)).toBe(true);
expect(long[kept.length]).toBe(" ");
});
// ---- planner ----
test("planInlineSync: creates new, patches live, stubs replied stale, deletes bare stale", () => {
const target = { fp: "aaaa11", finding: finding() };
const plan = planInlineSync({
targets: [target, { fp: "bbbb22", finding: finding({ file: "b.ts" }) }],
existing: [
existing({ id: 10, fp: "aaaa11" }),
existing({ id: 11, fp: "gone01", hasReplies: true }),
existing({ id: 12, fp: "gone02", hasReplies: false }),
],
maxComments: 20,
teardown: true,
});
expect(plan.patch.map((p) => p.comment.fp)).toEqual(["aaaa11"]);
expect(plan.create.map((c) => c.fp)).toEqual(["bbbb22"]);
expect(plan.stub.map((s) => s.fp)).toEqual(["gone01"]);
expect(plan.remove.map((r) => r.fp)).toEqual(["gone02"]);
});
test("planInlineSync: teardown=false is additive-only, but still removes duplicates of a kept fp", () => {
const plan = planInlineSync({
targets: [{ fp: "aaaa11", finding: finding() }],
existing: [
existing({ id: 10, fp: "aaaa11" }),
existing({ id: 9, fp: "aaaa11" }), // older duplicate (crash window)
existing({ id: 11, fp: "gone01" }),
existing({ id: 12, fp: "gone02", hasReplies: true }),
],
maxComments: 20,
teardown: false,
});
expect(plan.stub).toEqual([]);
expect(plan.remove.map((r) => r.id)).toEqual([9]); // duplicate only, never live stale threads
expect(plan.patch.map((p) => p.comment.id)).toEqual([10]); // newest duplicate is the live one
});
test("planInlineSync: the cap is sticky — a live thread is never evicted for a new finding", () => {
const critical = { fp: "cccc33", finding: finding({ severity: "critical", file: "c.ts" }) };
const existingWarning = { fp: "aaaa11", finding: finding() };
const plan = planInlineSync({
targets: [critical, existingWarning],
existing: [existing({ id: 10, fp: "aaaa11", hasReplies: true })],
maxComments: 1,
teardown: true,
});
// The warning already has a live thread; the (higher-severity) newcomer waits.
expect(plan.patch.map((p) => p.comment.fp)).toEqual(["aaaa11"]);
expect(plan.create).toEqual([]);
expect(plan.stub).toEqual([]);
});
test("planInlineSync: severity then fp orders admissions deterministically", () => {
const plan = planInlineSync({
targets: [
{ fp: "bbbb22", finding: finding({ file: "b.ts" }) },
{ fp: "aaaa11", finding: finding() },
{ fp: "cccc33", finding: finding({ severity: "critical", file: "c.ts" }) },
],
existing: [],
maxComments: 2,
teardown: true,
});
expect(plan.create.map((c) => c.fp)).toEqual(["cccc33", "aaaa11"]);
});
// ---- body compare + marker scan ----
test("inlineBodiesEqual is CRLF-insensitive (the API returns CRLF bodies)", () => {
expect(inlineBodiesEqual("a\r\nb", "a\nb")).toBe(true);
expect(inlineBodiesEqual("a\nb", "a\nc")).toBe(false);
});
test("hasUnquotedMarker: quoted copies do not count", () => {
const marker = inlineCommentMarker(TAG, "deadbeef11");
expect(hasUnquotedMarker(`${marker}\nbody`, marker)).toBe(true);
expect(hasUnquotedMarker(`> ${marker}\nmy actual reply`, marker)).toBe(false);
});
// ---- reply-matcher backstop ----
test("hasUnquotedInlineMarker: tag-independent, unquoted lines only", () => {
expect(hasUnquotedInlineMarker(renderInlineCommentBody(finding(), "any-tag", "abcdef"))).toBe(
true,
);
expect(hasUnquotedInlineMarker(`> <!-- any-tag:inline:fp=abcdef -->\nreal reply`)).toBe(false);
});
test("matchReplies: our inline body is never matched as a reply, even with a maintainer login", () => {
const f = finding();
const fp = fingerprintFinding(f);
const body = renderInlineCommentBody(f, "some-other-tag", fp); // carries an unquoted id: token
const records = matchReplies(
[{ id: 5, body, login: "bot", maintainer: true }],
[{ finding: f, fp }],
{
match: "both",
},
);
expect(records).toEqual([]);
});
test("matchReplies: a quote-reply of an inline comment still matches", () => {
const f = finding();
const fp = fingerprintFinding(f);
const quoted = renderInlineCommentBody(f, TAG, fp)
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
const records = matchReplies(
[{ id: 5, body: `${quoted}\n\nThis is intentional.`, login: "author", maintainer: false }],
[{ finding: f, fp }],
{ match: "both" },
);
expect(records.map((r) => r.fp)).toEqual([fp]);
expect(records[0]!.citedId).toBe(false); // a quoted id never counts as citing
});
// ---- threadFp ----
test("matchReplies: threadFp matches in every match mode and never sets citedId", () => {
const f = finding();
const fp = fingerprintFinding(f);
for (const match of ["quote", "id", "both"] as const) {
const records = matchReplies(
[
{
id: -50,
body: "we cache these on purpose",
login: "author",
maintainer: false,
threadFp: fp,
},
],
[{ finding: f, fp }],
{ match },
);
expect(records.map((r) => r.fp)).toEqual([fp]);
expect(records[0]!.citedId).toBe(false);
expect(records[0]!.commentId).toBe(-50);
}
});
test("matchReplies: an unknown threadFp matches nothing", () => {
const f = finding();
const records = matchReplies(
[{ id: -50, body: "x", login: "author", maintainer: false, threadFp: "ffffff" }],
[{ finding: f, fp: fingerprintFinding(f) }],
{ match: "both" },
);
expect(records).toEqual([]);
});
// ---- cross-stream ids ----
test("replyIsNewer: magnitude within a stream, issue comments win across streams", () => {
expect(replyIsNewer(200, 100)).toBe(true); // issue: newer wins
expect(replyIsNewer(-200, -100)).toBe(true); // inline: raw 200 is newer than raw 100
expect(replyIsNewer(-100, -200)).toBe(false);
expect(replyIsNewer(100, -999)).toBe(true); // cross-stream: issue comment wins
expect(replyIsNewer(-999, 100)).toBe(false);
});
test("matchReplies: the newer inline reply wins within the inline stream", () => {
const f = finding();
const fp = fingerprintFinding(f);
const mk = (id: number, login: string): ReplyComment => ({
id,
body: "",
login,
maintainer: false,
threadFp: fp,
});
const records = matchReplies([mk(-100, "older"), mk(-200, "newer")], [{ finding: f, fp }], {
match: "both",
});
expect(records[0]!.by).toBe("newer");
});
test("applyPins: a cross-stream 'newer' maintainer reply never lifts a pin; same-stream magnitude does", () => {
const record = (commentId: number): FeedbackRecord => ({
fp: "aaaa11",
by: "m",
commentId,
maintainer: true,
citedId: true,
applied: false,
});
// Pin taken against an inline reply (negative id): an issue reply cannot lift it.
expect(applyPins([record(999)], [{ fp: "aaaa11", commentId: -100 }]).pins).toHaveLength(1);
// A NEWER inline maintainer reply (raw 200 > raw 100) lifts it.
expect(applyPins([record(-200)], [{ fp: "aaaa11", commentId: -100 }]).pins).toHaveLength(0);
// An OLDER inline reply does not.
expect(applyPins([record(-50)], [{ fp: "aaaa11", commentId: -100 }]).pins).toHaveLength(1);
});
// ---- activeFindings ----
test("activeFindings: drops dismissed, reply-cleared, and requalified findings", () => {
const keep = { finding: finding(), fp: "keep01" };
const dismissed = { finding: finding({ file: "b.ts" }), fp: "gone01" };
const cleared = { finding: finding({ file: "c.ts" }), fp: "gone02" };
const requalified = {
finding: finding({ file: "d.ts", requalifiedBy: { prNumber: 2, file: "x", reason: "r" } }),
fp: "gone03",
};
const record: FeedbackRecord = {
fp: "gone02",
by: "author",
commentId: 5,
maintainer: false,
applied: true,
};
const active = activeFindings(
[keep, dismissed, cleared, requalified],
[{ fp: "gone01" }],
[record],
);
expect(active.map((entry) => entry.fp)).toEqual(["keep01"]);
});
// ---- short form ----
test("renderMarkdown: an inlined finding renders short with the thread link; others render full", () => {
const inlined = finding({ suggestion: "cache the observer" });
const other = finding({ file: "b.ts", title: "Other finding", suggestion: "do the thing" });
const review: CoordinatorOutput = {
decision: "approve_with_comments",
findings: [inlined, other],
summary: "s",
incomplete: [],
};
const link: LinkContext = {
repo: "o/r",
prNumber: 1,
inlineUrls: new Map([
[fingerprintFinding(inlined), "https://github.com/o/r/pull/1#discussion_r123"],
]),
};
const body = renderMarkdown(review, TAG, [], link);
expect(body).toContain("[inline comment](https://github.com/o/r/pull/1#discussion_r123)");
// Short form: the one-line rationale survives, the suggestion moves inline-only.
expect(body).toContain("getInstances() restarts the push token observer");
expect(body).not.toContain("cache the observer");
// The non-inlined finding keeps its full form.
expect(body).toContain("**Suggestion:** do the thing");
});
test("renderMarkdown: a non-github inline URL is ignored (full form)", () => {
const f = finding({ suggestion: "s1" });
const review: CoordinatorOutput = {
decision: "approve_with_comments",
findings: [f],
summary: "s",
incomplete: [],
};
const link: LinkContext = {
repo: "o/r",
prNumber: 1,
inlineUrls: new Map([[fingerprintFinding(f), "https://evil.example/x"]]),
};
const body = renderMarkdown(review, TAG, [], link);
expect(body).not.toContain("evil.example");
expect(body).toContain("**Suggestion:** s1");
});
+80
View File
@@ -13,6 +13,7 @@ import {
resolveEngineDispatch,
resolveOpencodeCli,
STACK_VERIFIER_AGENT,
META_MODEL_API_BASE_URL,
} from "../core/opencode.js";
import type { OpencodeHandle } from "../core/opencode.js";
import type { ClaudeCodeHandle } from "../core/claude-code.js";
@@ -341,6 +342,85 @@ test("an upstream-alias auth entry synthesizes a provider block with exactly the
expect(opencode.provider?.openai).toBeUndefined();
});
test("Meta auth synthesizes the public Muse Responses provider without embedding its key", () => {
const config = configWith({
agents: [{ id: "correctness", model: "meta/muse-spark-1.2" }],
coordinatorModel: "meta/muse-spark-1.2",
auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }],
});
const opencode = buildOpencodeConfig(config) as {
provider?: Record<
string,
{
npm: string;
options: { baseURL: string; apiKey: string };
models: Record<string, { reasoning?: boolean; options?: Record<string, unknown> }>;
}
>;
};
const meta = opencode.provider?.meta;
expect(meta).toBeDefined();
expect(meta!.npm).toBe("@ai-sdk/openai");
expect(meta!.options).toEqual({
baseURL: META_MODEL_API_BASE_URL,
apiKey: "{env:META_API_KEY}",
});
expect(Object.keys(meta!.models)).toEqual(["muse-spark-1.2"]);
expect(meta!.models["muse-spark-1.2"]?.reasoning).toBe(true);
expect(meta!.models["muse-spark-1.2"]?.options).toMatchObject({
reasoningEffort: "high",
include: ["reasoning.encrypted_content"],
});
});
test("a Meta-named upstream alias does not redirect the upstream credential to Meta", () => {
const config = configWith({
agents: [{ id: "correctness", model: "meta/muse-spark-1.2" }],
coordinatorModel: "meta/muse-spark-1.2",
auth: [
{
mode: "api-key",
provider: "meta",
tokenEnv: "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN",
upstream: "anthropic",
},
],
});
const opencode = buildOpencodeConfig(config) as {
provider?: Record<
string,
{ npm: string; options: { apiKey: string; baseURL?: string }; models: object }
>;
};
const alias = opencode.provider?.meta;
expect(alias).toBeDefined();
expect(alias!.npm).toBe("@ai-sdk/anthropic");
expect(alias!.options).toEqual({ apiKey: "{env:CLAUDE_CODE_REVIEW_SHARED_API_TOKEN}" });
expect(alias!.options.baseURL).toBeUndefined();
expect(Object.keys(alias!.models)).toEqual(["muse-spark-1.2"]);
});
test("Meta provider does not register an unknown Muse id, so preflight can reject it", () => {
const config = configWith({
agents: [{ id: "correctness", model: "meta/muse-spark-typo" }],
coordinatorModel: "meta/muse-spark-typo",
auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }],
});
const opencode = buildOpencodeConfig(config) as {
provider?: Record<string, { models: Record<string, unknown> }>;
};
expect(opencode.provider?.meta?.models).toEqual({});
expect(
findUnknownModels(["meta/muse-spark-typo"], { meta: ["muse-spark-1.2"] }, ["meta"]),
).toEqual([
{
model: "meta/muse-spark-typo",
reason: "model",
suggestions: ["muse-spark-1.2"],
},
]);
});
test("buildOpencodeConfig registers the no-tools stack verifier (empty tool list)", () => {
const opencode = buildOpencodeConfig(configWith({})) as {
agent: Record<string, { tools: Record<string, boolean> }>;
+4 -4
View File
@@ -61,7 +61,7 @@ test("aggregate: root marker first line, a table row + <details> per scope, wors
expect(body.split("\n")[0]).toBe(commentMarker("tag"));
expect(body).toContain("**Decision:** Request changes"); // worst across scopes
expect(body).toContain("| Scope | Decision | Findings |");
expect(body).toContain("| default | Approve | 1 |");
expect(body).toContain("| default | Ready for human review | 1 |");
expect(body).toContain("| api | Request changes | 1 |");
// one details block per scope, in manifest order
expect(body.indexOf("<summary>default")).toBeLessThan(body.indexOf("<summary>api"));
@@ -141,7 +141,7 @@ test("aggregate: a default-scope finding dismissed pre-routing (plain fp) lands
]);
expect(body).toContain("Dismissed on this PR (1)");
expect(body).toContain(`id:${plainFp}`);
expect(body).toContain("| default | Approve | 0 |"); // moved out of the active count
expect(body).toContain("| default | Ready for human review | 0 |"); // moved out of the active count
});
test("aggregate: dismissed findings survive in embedded state so /undismiss can restore them", () => {
@@ -162,7 +162,7 @@ test("aggregate: dismissed findings survive in embedded state so /undismiss can
// (what /undismiss does) restores the finding to the active list.
const restored = renderAggregateMarkdown(state.scopes!, "tag", []);
expect(restored).toContain(`id:${fp}`);
expect(restored).toContain("| api | Approve | 2 |");
expect(restored).toContain("| api | Ready for human review | 2 |");
expect(restored).not.toContain("Dismissed on this PR");
});
@@ -220,7 +220,7 @@ test("aggregate: a scope whose reply cleared every finding opens its fold (visib
],
);
// The finding is suppressed (moved to Dismissed); the scope shows no active finding.
expect(body).toContain("<summary>api — Approve (0)</summary>");
expect(body).toContain("<summary>api — Ready for human review (0)</summary>");
// ...but the fold OPENS so the audit note is above-the-fold, not collapsed away.
expect(body).toMatch(/<details open>\n<summary>api —/);
expect(body).toContain("have an author response");
@@ -221,4 +221,16 @@ test("direct URL inference prefers narrow release and dependency providers", ()
"https://developer.android.com/reference/androidx/media3/common/Player",
).provider,
).toBe("media3");
expect(
resolveDirectDocumentationTarget("https://developer.android.com/ndk/guides/cpp-support"),
).toMatchObject({ provider: "android-ndk", sourceKind: "official-guide" });
expect(resolveDirectDocumentationTarget("https://fetch.spec.whatwg.org/")).toMatchObject({
provider: "web-standards",
sourceKind: "standard",
});
expect(
resolveDirectDocumentationTarget(
"https://chromedevtools.github.io/devtools-protocol/tot/Runtime/",
),
).toMatchObject({ provider: "chrome-devtools-protocol", sourceKind: "standard" });
});
+13 -1
View File
@@ -39,12 +39,18 @@ test("stdio MCP advertises the read-only documentation tools and their enforced
);
expect(searchTool?.description ?? "").toContain("Native source retains platform context");
expect(searchTool?.description ?? "").toContain("react-native-worklets=Worklets");
expect(searchTool?.description ?? "").toContain("metro=Metro bundler");
expect(searchTool?.description ?? "").toContain(
"web-standards=WHATWG and W3C web platform standards",
);
expect(searchTool?.description ?? "").toContain("android-ndk=Android NDK");
expect(fetchTool?.annotations?.readOnlyHint).toBe(true);
expect(fetchTool?.description ?? "").toMatch(/every redirect/);
const inputSchema = searchTool?.inputSchema as {
properties?: {
query?: { description?: string };
providers?: { description?: string };
providers?: { description?: string; items?: { enum?: string[] } };
sourceKinds?: { description?: string; items?: { enum?: string[] } };
};
};
const fetchInputSchema = fetchTool?.inputSchema as {
@@ -59,6 +65,12 @@ test("stdio MCP advertises the read-only documentation tools and their enforced
expect(inputSchema.properties?.providers?.description ?? "").toContain(
"jetbrains-issues=JetBrains YouTrack context",
);
expect(inputSchema.properties?.providers?.items?.enum).toContain("typescript");
expect(inputSchema.properties?.providers?.items?.enum).toContain("chrome-devtools-protocol");
expect(inputSchema.properties?.sourceKinds?.items?.enum).toContain("standard");
expect(inputSchema.properties?.sourceKinds?.description ?? "").toContain(
"external compatibility specification",
);
expect(fetchInputSchema.properties?.context?.default).toBe("section");
expect(fetchInputSchema.properties?.context?.description ?? "").toContain(
"document=bounded extracted page text",
@@ -1,22 +1,31 @@
import { expect, test } from "bun:test";
import {
androidNdkProvider,
androidProvider,
appleProvider,
appleReleasesProvider,
chromeDevtoolsProtocolProvider,
cmakeProvider,
cocoaPodsProvider,
expoProvider,
flowProvider,
glideProvider,
jetbrainsIssuesProvider,
metroProvider,
okHttpProvider,
reactNativeGestureHandlerProvider,
reactNativeProvider,
reactNativeReanimatedProvider,
reactNativeScreensProvider,
reactNativeWorkletsProvider,
reactProvider,
resolveAllowedUrl,
resolveAllowedRequestUrl,
sdWebImageProvider,
swiftEvolutionProvider,
typescriptProvider,
webStandardsProvider,
} from "../../research-mcp/providers.js";
test("Apple provider accepts only HTTPS documentation paths on the exact host", () => {
@@ -180,6 +189,7 @@ test("React Native ecosystem providers stay on their exact documentation hosts a
reactNativeWorkletsProvider,
"https://docs.swmansion.com/react-native-worklets/docs/fundamentals/getting-started/",
],
[metroProvider, "https://metrobundler.dev/docs/configuration"],
] as const) {
expect(resolveAllowedUrl(provider, url).hostname).toBe(new URL(url).hostname);
}
@@ -193,4 +203,53 @@ test("React Native ecosystem providers stay on their exact documentation hosts a
expect(() => resolveAllowedUrl(expoProvider, "https://expo.dev/accounts")).toThrow(
/outside the expo documentation allowlist/,
);
expect(() =>
resolveAllowedUrl(metroProvider, "https://metrobundler.dev.evil.example/docs/configuration"),
).toThrow(/outside the metro documentation allowlist/);
});
test("React Native contract providers admit only their fixed authoritative corpora", () => {
for (const [provider, url] of [
[reactProvider, "https://react.dev/reference/react/useEffect"],
[flowProvider, "https://flow.org/en/docs/types/unions/"],
[typescriptProvider, "https://www.typescriptlang.org/docs/handbook/declaration-files/"],
[androidNdkProvider, "https://developer.android.com/ndk/guides/cpp-support"],
[cmakeProvider, "https://cmake.org/cmake/help/v3.31/command/add_library.html"],
[cocoaPodsProvider, "https://guides.cocoapods.org/syntax/podfile.html"],
[webStandardsProvider, "https://fetch.spec.whatwg.org/"],
[webStandardsProvider, "https://w3c.github.io/IntersectionObserver/"],
[
chromeDevtoolsProtocolProvider,
"https://chromedevtools.github.io/devtools-protocol/tot/Runtime/",
],
] as const) {
expect(resolveAllowedUrl(provider, url).hostname).toBe(new URL(url).hostname);
}
for (const [provider, url] of [
[reactProvider, "https://react.dev/reference/react-dom/components/form"],
[reactProvider, "https://react.dev/learn/escape-hatches"],
[flowProvider, "https://flow.org/blog/"],
[typescriptProvider, "https://www.typescriptlang.org/play"],
[androidNdkProvider, "https://developer.android.com/studio/projects/install-ndk"],
[cmakeProvider, "https://cmake.org/community/"],
[cocoaPodsProvider, "https://guides.cocoapods.org/blog/"],
[webStandardsProvider, "https://w3c.github.io/permissions/"],
[webStandardsProvider, "https://whatwg.org/"],
[chromeDevtoolsProtocolProvider, "https://chromedevtools.github.io/debugger-protocol-viewer/"],
] as const) {
expect(() => resolveAllowedUrl(provider, url)).toThrow(
/outside the .* documentation allowlist/,
);
}
expect(() =>
resolveAllowedUrl(webStandardsProvider, "https://user:[email protected]/"),
).toThrow(/outside the web-standards documentation allowlist/);
expect(() =>
resolveAllowedUrl(androidNdkProvider, "https://developer.android.com.evil.example/ndk/guides"),
).toThrow(/outside the android-ndk documentation allowlist/);
expect(() =>
resolveAllowedUrl(reactNativeProvider, "https://reactnative.dev/contributing/overview"),
).toThrow(/outside the react-native documentation allowlist/);
});
@@ -299,3 +299,67 @@ test("Apple release searches select the platform-specific fixed scope", async ()
expect(queries[0]).toContain("site:developer.apple.com/documentation/xcode-release-notes");
expect(queries[1]).toContain("site:developer.apple.com/documentation/ios-ipados-release-notes");
});
test("web standards discovery uses fixed WHATWG and W3C scopes with standards provenance", async () => {
let searchRequests = 0;
const fetchImplementation = async (input: string | URL | Request): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : input.toString());
if (url.hostname === "api.search.brave.com") {
searchRequests++;
const query = url.searchParams.get("q") ?? "";
expect(query).toContain("site:whatwg.org");
expect(query).toContain("site:w3c.github.io");
return jsonResponse({
web: {
results: [
{ title: "Fetch Standard", url: "https://fetch.spec.whatwg.org/" },
{ title: "Unrelated W3C draft", url: "https://w3c.github.io/permissions/" },
],
},
});
}
expect(url.href).toBe("https://fetch.spec.whatwg.org/");
return new Response(
"<!doctype html><html><head><title>Fetch Standard</title></head><body><main>" +
"<h1>Fetch</h1><p>The AbortSignal controls cancellation of a fetch request and " +
"causes the fetch promise to reject when the operation is terminated.</p>" +
"</main></body></html>",
{ headers: { "content-type": "text/html; charset=utf-8" } },
);
};
const response = await searchRemoteDocumentation(
"web-standards",
"AbortSignal fetch cancellation",
2,
{ apiKey: "test-key", fetchImplementation, sourceKinds: ["standard"] },
);
expect(searchRequests).toBe(1);
expect(response.warnings).toEqual([]);
expect(response.results).toHaveLength(1);
expect(response.results[0]).toMatchObject({
provider: "web-standards",
sourceKind: "standard",
url: "https://fetch.spec.whatwg.org/",
});
});
test("provenance filtering skips a standards provider before network access", async () => {
let requests = 0;
const response = await searchRemoteDocumentation(
"chrome-devtools-protocol",
"Runtime evaluate",
1,
{
apiKey: "test-key",
sourceKinds: ["official-api"],
fetchImplementation: async () => {
requests++;
return jsonResponse({ web: { results: [] } });
},
},
);
expect(requests).toBe(0);
expect(response).toEqual({ results: [], warnings: [] });
});
@@ -149,6 +149,40 @@ test("search filters named corpora and provenance classes", () => {
]);
});
test("search filters external standards separately from implementation documentation", () => {
const index = buildSearchIndex([
{
id: "standard:fetch",
platform: "react-native",
provider: "web-standards",
sourceKind: "standard",
title: "Fetch Standard",
url: "https://fetch.spec.whatwg.org/",
passage: "AbortSignal controls cancellation of a fetch request.",
indexedAt: "2026-08-06T00:00:00.000Z",
},
{
id: "rn:fetch",
platform: "react-native",
provider: "react-native",
sourceKind: "official-api",
title: "React Native global fetch",
url: "https://reactnative.dev/docs/global-fetch",
passage: "React Native exposes fetch as a global API.",
indexedAt: "2026-08-06T00:00:00.000Z",
},
]);
const results = searchDocumentation(index, "fetch AbortSignal", {
platform: "react-native",
sourceKinds: ["standard"],
limit: 5,
});
expect(results.map(({ provider, sourceKind }) => ({ provider, sourceKind }))).toEqual([
{ provider: "web-standards", sourceKind: "standard" },
]);
});
test("a language filter excludes otherwise matching untagged documents", () => {
const index = buildSearchIndex([
...chunks,
+3
View File
@@ -52,6 +52,7 @@ test("the wrapper hands the MCP server a constructed environment, not an inherit
PATH: process.env.PATH ?? "",
HOME: "/root",
ANTHROPIC_API_KEY: "must-not-reach-the-server",
META_API_KEY: "must-not-reach-the-server",
CLAUDE_CODE_OAUTH_TOKEN: "must-not-reach-the-server",
AWS_SECRET_ACCESS_KEY: "must-not-reach-the-server",
GITHUB_TOKEN: "must-not-reach-the-server",
@@ -93,6 +94,7 @@ test("research child environment forwards only locale, proxy variables, and the
no_proxy: "localhost",
BRAVE_SEARCH_API_KEY: "search-only",
SECRET_TOKEN: "must-not-be-forwarded",
META_API_KEY: "must-not-be-forwarded",
PATH: "/private/bin",
}),
).toMatchObject({
@@ -103,6 +105,7 @@ test("research child environment forwards only locale, proxy variables, and the
BRAVE_SEARCH_API_KEY: "search-only",
});
expect(researchChildEnvironment({ SECRET_TOKEN: "nope" })).not.toHaveProperty("SECRET_TOKEN");
expect(researchChildEnvironment({ META_API_KEY: "nope" })).not.toHaveProperty("META_API_KEY");
expect(researchChildEnvironment({ PATH: "/private/bin" })).not.toHaveProperty("PATH");
});
+28 -1
View File
@@ -4,7 +4,12 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { LoadedConfig } from "../config/schema.js";
import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
import {
reviewCacheAllowed,
reviewCanBeReused,
reviewInputHash,
reviewMatchesInput,
} from "../core/review-cache.js";
import type { CoordinatorOutput, DiffEntry } from "../core/schema.js";
const config = (over: Partial<LoadedConfig> = {}): LoadedConfig => ({
@@ -171,3 +176,25 @@ test("file hashing refuses traversal and never follows a PR-controlled symlink",
await rm(outside, { force: true });
}
});
test("reviewCacheAllowed is the one policy both CI paths share", () => {
const base = {
bypassTriggerGate: false,
stack: false,
feedback: false,
hasMetadata: true,
};
expect(reviewCacheAllowed(base)).toBe(true);
// Each flag independently means "this run has an input the key can't represent".
expect(reviewCacheAllowed({ ...base, bypassTriggerGate: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, stack: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, feedback: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, hasMetadata: false })).toBe(false);
// Research is deliberately NOT a gate. It was one only because the offline index
// was an artifact the key could not represent; with the index gone, a researched
// review fetches everything live during the run. The routed path kept this gate
// after the legacy path dropped it, which is the drift this function prevents.
expect(reviewCacheAllowed(base)).toBe(true);
});
+8
View File
@@ -14,6 +14,14 @@ test("plan: the mixed setup needs one ChatGPT login and one manual key", () => {
expect(plan.unsupported).toEqual([]);
});
test("plan: Meta Model API is a manual API key setup", () => {
const plan = planFromAuth([{ provider: "meta", mode: "api-key", tokenEnv: "META_API_KEY" }]);
expect(plan.manualKeys).toEqual([
{ provider: "meta", tokenEnv: "META_API_KEY", upstream: undefined },
]);
expect(plan.unsupported).toEqual([]);
});
test("plan: an anthropic entry (any mode) → claudeLogin; api-key without tokenEnv needs nothing", () => {
const plan = planFromAuth([
// Mode is irrelevant for anthropic — it is always served by the Claude Code CLI.
+2 -2
View File
@@ -82,9 +82,9 @@ test("renderAggregateMarkdown: per-scope addressed bucket, count excludes requal
const out = renderAggregateMarkdown(results, "tag", []);
// Worst decision across scopes is approve_with_comments (the requalified-only scope
// contributes approve, not request_changes).
expect(out).toContain("**Decision:** Approve with comments");
expect(out).toContain("**Decision:** Ready for human review (with comments)");
// The api scope's blocking count is 0 (the requalified finding is not counted).
expect(out).toMatch(/\| api \| Approve \| 0 \|/);
expect(out).toMatch(/\| api \| Ready for human review \| 0 \|/);
expect(out).toContain("Addressed in stacked PRs (1)");
expect(out).toContain("marked addressed by stacked PR(s) (#42)");
});
+34 -15
View File
@@ -30,7 +30,12 @@ import { summarizePriorReview } from "../core/prior-review.js";
import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
import { runReview } from "../core/review.js";
import type { ReviewRunOptions, ReviewRunResult } from "../core/review.js";
import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
import {
reviewCacheAllowed,
reviewCanBeReused,
reviewInputHash,
reviewMatchesInput,
} from "../core/review-cache.js";
import { GitHubPRSource } from "../sources/github-pr.js";
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
import type { PreparedReadRoot, ReviewSource, StackWalkOptions } from "../sources/source.js";
@@ -558,6 +563,7 @@ async function runLegacyCi(
breakGlassMarker: config.breakGlassMarker,
cwd,
feedback: config.feedback,
inline: config.inline,
headSha,
});
@@ -576,13 +582,12 @@ async function runLegacyCi(
const stack = resolveStackWalk(config.stack, noStackAware);
const stackConfirm = resolveStackConfirm(config.stack, noStackAware);
const feedback = adjudicationSeam(config, reporter);
// Dynamic stack context and model-backed reply adjudication have inputs outside
// the scoped diff. Keep those paths fresh until their inputs join the cache key.
// A maintainer's explicit /review is also always a real rerun.
// Research no longer forces a fresh run: with the local index gone, every result
// is fetched live from an allowlisted host during the run, so there is no mounted
// artifact whose contents could drift out from under the cache key.
const cacheAllowed = !bypassTriggerGate && !stack && !feedback && metadata !== undefined;
const cacheAllowed = reviewCacheAllowed({
bypassTriggerGate,
stack: Boolean(stack),
feedback: Boolean(feedback),
hasMetadata: metadata !== undefined,
});
let inputHash: string | undefined;
// The previous review's embedded comment state, read ONCE: the cache check below
@@ -681,6 +686,10 @@ async function runLegacyCi(
findings: [],
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
incomplete: [],
// A failed run's empty findings list proves nothing: this flag is what keeps
// the inline sync additive-only (no thread teardown) — and it renders the
// accurate "No review" decision label.
couldNotComplete: true,
});
} catch (postError) {
process.stderr.write(
@@ -697,6 +706,10 @@ function failureReview(scopeName: string, reason: string): CoordinatorOutput {
findings: [],
summary: `⚠️ The AI reviewer failed to run for scope **${scopeName}**, so those changes were **not** reviewed:\n\n> ${reason}`,
incomplete: [],
// Empty-but-not-clean: keeps the inline sync additive-only for the whole
// aggregate (a failed scope's findings may still be live on the PR) and renders
// the accurate "No review" label in the scope table.
couldNotComplete: true,
};
}
@@ -885,6 +898,7 @@ async function runRoutedCi(
// Root-only feedback config (see loadScopeConfig): lets a reporter posting with
// no explicit records match replies itself (annotate mode) at report time.
feedback: rootConfig.feedback,
inline: rootConfig.inline,
headSha,
});
@@ -905,12 +919,12 @@ async function runRoutedCi(
reporterFor(scopedCommentTag(rootTag, name), true),
);
const cacheAllowed =
!bypassTriggerGate &&
!stackWalk &&
!feedbackNeedsRunSeam(rootConfig.feedback) &&
!rootConfig.research.enabled &&
metadata !== undefined;
const cacheAllowed = reviewCacheAllowed({
bypassTriggerGate,
stack: Boolean(stackWalk),
feedback: feedbackNeedsRunSeam(rootConfig.feedback),
hasMetadata: metadata !== undefined,
});
let cacheReadRoot: string | undefined;
if (cacheAllowed) {
try {
@@ -1119,7 +1133,12 @@ async function runRoutedCi(
aggState?.pins,
)
: undefined;
await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback);
// A --scopes partial run carries the other scopes out of (possibly truncated)
// embedded state, so its target set is incomplete: inline teardown must not
// run (failed scopes are handled inside reportAggregate itself).
await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback, {
inlineTeardown: !scopesFilter,
});
}
// Clean up any per-scope comments from a previous per-scope run. A partial run
// only ever touches the named scopes' comments.
+2
View File
@@ -98,6 +98,8 @@ export async function dismissCommand(argv: string[], mode: "add" | "remove"): Pr
// feedback policy in force now — without it a reply-cleared finding would be
// un-hidden by an unrelated /dismiss.
feedback: config.feedback,
// Lets the dismissal stub/revive the finding's inline thread (additive-only).
inline: config.inline,
});
const result = await reporter.applyDismissal(
mode === "add" ? args.ids : [],
+1
View File
@@ -147,6 +147,7 @@ export async function postReviewCommand(argv: string[]): Promise<void> {
breakGlassMarker: config.breakGlassMarker,
cwd,
feedback: config.feedback,
inline: config.inline,
headSha,
});
const result = await publishDeferredReview(reporter, artifact, async () => {
+3
View File
@@ -276,6 +276,8 @@ export async function reviewCommand(argv: string[]): Promise<void> {
cwd,
// Root-only feedback config (loadScopeConfig inherits it from the root).
feedback: config.feedback,
// Root-only too (loadScopeConfig inherits it from the root).
inline: config.inline,
headSha: await reviewedHeadSha(source),
})
: null;
@@ -361,6 +363,7 @@ export async function reviewCommand(argv: string[]): Promise<void> {
breakGlassMarker: config.breakGlassMarker,
cwd,
feedback: config.feedback,
inline: config.inline,
headSha,
})
: null;
+6 -1
View File
@@ -25,7 +25,8 @@ getting each credential:
opens your browser) and prints the \`export <tokenEnv>=…\` line for
CI/headless runs.
• an API key (api-key entries): prints where to create the key, the exact
permissions it needs, and the export line to fill in.
permissions it needs, and the export line to fill in. Meta Model API
(\`meta/muse-spark-…\`) uses META_API_KEY from the Meta AI developer portal.
Without a repo config, it offers the recommended ChatGPT/Codex subscription flow
with the default env name.
@@ -300,6 +301,10 @@ export async function setupAuthCommand(argv: string[] = []): Promise<void> {
} else if (upstream === "anthropic") {
err(" https://console.anthropic.com/settings/keys — a workspace-scoped key");
err(" with a spend limit is all the reviewer needs.");
} else if (upstream === "meta") {
// @ref LLP 0007#doctor-and-setup-auth [implements] — Meta uses a manual API key
err(" https://developer.meta.com/ai/ — create a Model API key with access to");
err(" Muse Spark, then store it as a dedicated review secret.");
} else {
err(` mint a key for the "${upstream}" provider.`);
}
+11 -1
View File
@@ -39,6 +39,12 @@ const FEEDBACK_CONFIG_DEFAULTS: LoadedConfig["feedback"] = {
maxAdjudications: 10,
};
/** Inline-comment config for a scope load (where `inline` is schema-rejected). */
const INLINE_CONFIG_DEFAULTS: LoadedConfig["inline"] = {
enabled: false,
maxComments: 20,
};
/** Research defaults for a scope load (where `research` is schema-rejected). */
const RESEARCH_CONFIG_DEFAULTS: LoadedConfig["research"] = {
enabled: false,
@@ -76,7 +82,7 @@ export interface LoadConfigOptions {
/** Parsed config with the centrally-locked keys optional (scope configs omit them). */
type ParsedConfig = Omit<
RawReviewConfig,
"auth" | "breakGlass" | "commentTag" | "stack" | "feedback" | "research"
"auth" | "breakGlass" | "commentTag" | "stack" | "feedback" | "research" | "inline"
> & {
auth?: RawReviewConfig["auth"];
breakGlass?: RawReviewConfig["breakGlass"];
@@ -84,6 +90,7 @@ type ParsedConfig = Omit<
stack?: RawReviewConfig["stack"];
feedback?: RawReviewConfig["feedback"];
research?: RawReviewConfig["research"];
inline?: RawReviewConfig["inline"];
};
export function hasConfig(repoRoot: string, options: LoadConfigOptions = {}): boolean {
@@ -219,6 +226,8 @@ async function loadConfigDir(
// for a scope config and the defaults stand in (unused — the command layer
// reads the ROOT config's feedback values; the comment lifecycle is global).
feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
// Root-only, same reasoning as feedback.
inline: parsed.inline ?? INLINE_CONFIG_DEFAULTS,
};
return { config, raw: rawObject };
}
@@ -386,6 +395,7 @@ export async function loadScopeConfig(
// run the default policy instead of the repo's real one.
stack: rootConfig.stack,
feedback: rootConfig.feedback,
inline: rootConfig.inline,
research: rootConfig.research,
scopeName: scope.name,
};
+26
View File
@@ -224,6 +224,20 @@ export const ReviewConfigSchema = z.object({
protectedCategories: ["secrets", "security"],
maxAdjudications: 10,
}),
// Inline PR review comments for findings anchored to a line in the diff: the
// main comment stays the durable state store and renders such findings short,
// linking to the inline thread. ROOT-ONLY (the comment lifecycle is global) and
// off by default — a feature that mutates N comments per run earns trust with
// field data first. Turning it off later leaves existing threads up (documented);
// clear() still sweeps them on comment-mode switches.
inline: z
.object({
enabled: z.boolean().default(false),
// Cap on inline comments PER REPORTER: per PR in single/legacy comment mode,
// per scope in per-scope mode. Bounds create-notification fan-out and API use.
maxComments: z.number().int().positive().default(20),
})
.default({ enabled: false, maxComments: 20 }),
});
export type RawReviewConfig = z.infer<typeof ReviewConfigSchema>;
@@ -335,6 +349,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
stack: true,
feedback: true,
research: true,
inline: true,
}).extend({
auth: z
.never({ error: "auth is locked to the root config; remove it from this scope config" })
@@ -364,6 +379,12 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
"research is locked to the root config because it starts a trusted host process; remove it from this scope config",
})
.optional(),
inline: z
.never({
error:
"inline is locked to the root config (the comment lifecycle is global); remove it from this scope config",
})
.optional(),
});
export type RawScopeReviewConfig = z.infer<typeof ScopeReviewConfigSchema>;
@@ -468,4 +489,9 @@ export interface LoadedConfig {
protectedCategories: Category[];
maxAdjudications: number;
};
/** Root-only inline-comment settings (see ReviewConfigSchema.inline). */
inline: {
enabled: boolean;
maxComments: number;
};
}
+1
View File
@@ -15,6 +15,7 @@ const PROVIDER_KEY_ENV: Record<string, string> = {
openai: "OPENAI_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
meta: "META_API_KEY",
};
/**
+3
View File
@@ -582,6 +582,9 @@ export function parseClaudeResult(stdout: string): ClaudeResult {
const tokens: TokenUsage = {
input: num(usage.input_tokens),
output: num(usage.output_tokens),
reasoning: num(
(usage.output_tokens_details as Record<string, unknown> | null | undefined)?.thinking_tokens,
),
cache: {
write: num(usage.cache_creation_input_tokens),
read: num(usage.cache_read_input_tokens),
+4
View File
@@ -56,6 +56,10 @@ export function reviewPostingConfigFingerprint(config: LoadedConfig): string {
commentTag: config.commentTag,
breakGlassMarker: config.breakGlassMarker,
feedback: config.feedback,
// Inline comments change what posting does (N review-comment mutations), so a
// saved review must not post under a different inline policy than it was
// saved with.
inline: config.inline,
}),
)
.digest("hex");
+60
View File
@@ -19,6 +19,44 @@ import type { ResearchMcpRuntime } from "./research.js";
/** Discriminant for the Claude Code CLI engine (see core/claude-code.ts). */
export const CLAUDE_CODE_ENGINE = "claude-code" as const;
/** Fixed so repo config cannot redirect the Meta credential. */
// @ref LLP 0003#muse-spark-via-meta-model-api [implements] — fixed endpoint and model allowlist
export const META_MODEL_API_BASE_URL = "https://api.meta.ai/v1";
/** Supported public Muse models. Unknown ids remain visible to preflight. */
export const META_MUSE_MODELS: Record<string, Record<string, unknown>> = {
"muse-spark-1.2": {
name: "Muse Spark 1.2",
reasoning: true,
limit: { context: 1_048_576, output: 131_072 },
modalities: {
input: ["text", "image", "pdf", "video"],
output: ["text"],
},
options: {
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
"muse-spark-1.2-contributor": {
name: "Muse Spark 1.2 Contributor",
reasoning: true,
limit: { context: 1_048_576, output: 131_072 },
modalities: {
input: ["text", "image", "pdf", "video"],
output: ["text"],
},
options: {
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
};
/**
* Resolve which engine an agent's pass dispatches to, and (when claude) which
* claude handle to run it against. The per-agent router (engineOf) wins; absent it
@@ -221,6 +259,28 @@ export function buildOpencodeConfig(
config.coordinator.model,
];
for (const entry of config.auth) {
if (
entry.provider === "meta" &&
entry.mode === "api-key" &&
entry.tokenEnv &&
!entry.upstream
) {
// Muse needs the Responses adapter; openai-compatible uses Chat Completions.
provider.meta = {
npm: "@ai-sdk/openai",
name: "Meta Model API",
options: {
baseURL: META_MODEL_API_BASE_URL,
apiKey: `{env:${entry.tokenEnv}}`,
},
models: Object.fromEntries(
Object.entries(META_MUSE_MODELS).filter(([model]) =>
referencedModels.includes(`meta/${model}`),
),
),
};
continue;
}
if (!entry.upstream || !entry.tokenEnv) {
continue;
}
+122 -2
View File
@@ -35,6 +35,16 @@ export interface LinkContext {
repo: string; // owner/repo
prNumber: number;
diffLines?: Map<string, Set<number>>;
/**
* Permalinks to this run's live inline finding comments, keyed by the SAME id the
* comment renders (plain fingerprint, or scope-namespaced for an aggregate). A
* finding with an entry renders in SHORT form — title, links, one-line rationale —
* because its full text lives in the inline thread; a finding without one renders
* in full exactly as before. Callers must pass a per-render COPY of a shared
* LinkContext (`{ ...link, inlineUrls }`), never mutate the shared object: the
* routed ci flow shares one context across every scope reporter.
*/
inlineUrls?: Map<string, string>;
/**
* The PR base commit SHA (the branch the PR targets, e.g. `main`). When a finding
* points at code NOT in the diff — a caller or helper the PR merely references,
@@ -86,9 +96,12 @@ export function buildDiffLineIndex(
return index;
}
// User-facing labels only — the wire/schema enum stays `approve` etc. The review
// is advisory and never approves anything, so the labels must not read as an
// approval either: a clean result is "ready for a human", not "approved".
const DECISION_LABEL: Record<Decision, string> = {
approve: "Approve",
approve_with_comments: "Approve with comments",
approve: "Ready for human review",
approve_with_comments: "Ready for human review (with comments)",
request_changes: "Request changes",
};
@@ -118,6 +131,29 @@ export function commentMarker(tag: string): string {
return `<!-- ${tag} -->`;
}
/**
* Per-finding identity marker of an inline (PR review) comment, always rendered at
* byte 0 of the body. Substring-safe against `commentMarker(tag)` and any derived
* scope tag (`<tag>:<scope>`) in both directions — the closing ` -->` differs —
* including a scope literally named `inline`.
*/
export function inlineCommentMarker(tag: string, fp: string): string {
return `<!-- ${tag}:inline:fp=${fp} -->`;
}
/**
* The fingerprint an inline comment of OURS carries, or null. Anchored at byte 0
* and constrained to the fingerprint alphabet, so a marker echoed later in a body
* (untrusted prose, a quote-reply) can never parse as identity — mechanical
* safety, not just the incidental "our renderer emits it first". Identity itself
* is author + this marker (see the reporter); the parse alone proves nothing.
*/
export function parseInlineMarkerFp(body: string, tag: string): string | null {
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = body.match(new RegExp(`^<!-- ${escapedTag}:inline:fp=([a-f0-9]{6,64}) -->`));
return match ? match[1]! : null;
}
// @ref LLP 0011#forged-state-markers [implements] — the first-match parsers make an earlier forged marker win, so untrusted prose never keeps a raw `<!--`
/**
* Neutralize anything that could impersonate this reviewer's embedded state
@@ -345,12 +381,49 @@ function sourceLabel(value: string): string {
.replace(/([\\[\]])/g, "\\$1");
}
/** Longest one-line rationale excerpt the short form keeps in the main comment. */
const SHORT_RATIONALE_CHARS = 160;
/**
* A rationale reduced to one plain line for the short (inlined) form: whitespace
* collapsed, every `<` escaped so a truncated HTML block (`<details>`) can never
* leave an unclosed tag in the comment, cut at a word boundary with an ellipsis.
* This excerpt is the audit trail that survives the PR author collapsing or
* resolving the inline thread, so it must always be non-empty when the rationale is.
*/
export function oneLineRationale(text: string): string {
const flat = text.replace(/</g, "&lt;").replace(/\s+/g, " ").trim();
if (flat.length <= SHORT_RATIONALE_CHARS) {
return flat;
}
const cut = flat.slice(0, SHORT_RATIONALE_CHARS);
const atWord = cut.slice(0, cut.lastIndexOf(" "));
return `${atWord.length > 0 ? atWord : cut}…`;
}
/** Only ever link to github.com from a stored/returned URL (see REPLY_URL_RE). */
function validInlineUrl(link: LinkContext | undefined, id: string): string | null {
const url = link?.inlineUrls?.get(id);
return url && REPLY_URL_RE.test(url) ? url : null;
}
function renderFindingLines(
finding: Finding,
link?: LinkContext,
id: string = fingerprintFinding(finding),
reply?: FeedbackRecord,
): string[] {
const inlineUrl = validInlineUrl(link, id);
if (inlineUrl) {
// Short form: the full text lives in the inline thread on the flagged line.
// The one-line rationale stays here as the durable audit trail (the author
// can collapse/resolve the thread; they cannot edit this comment).
return [
`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\` · 💬 [inline comment](${inlineUrl})${replyAnnotation(reply)}`,
...indentContinuation(oneLineRationale(stripStateMarkers(finding.rationale))),
"",
];
}
const out = [
`- **${stripStateMarkers(finding.title)}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\`${replyAnnotation(reply)}`,
...indentContinuation(stripStateMarkers(finding.rationale)),
@@ -373,6 +446,53 @@ function renderFindingLines(
return out;
}
/**
* Full body of one inline (PR review) finding comment: identity marker at byte 0,
* then the finding rendered whole — everything model-written passes
* stripStateMarkers, same as the main comment. The footer teaches the clearing
* flow (an UNQUOTED id citation), since the main comment only shows the short form.
* The body must never contain `:state=`/`:fingerprints=` (it would be dropped as
* our own main comment by the reply matcher's backstop) — it doesn't.
*/
export function renderInlineCommentBody(finding: Finding, tag: string, fp: string): string {
const lines = [
inlineCommentMarker(tag, fp),
`**${severityHeading(finding.severity)}: ${stripStateMarkers(finding.title)}** _(${finding.category})_ · \`id:${fp}\``,
"",
stripStateMarkers(finding.rationale),
];
if (finding.sources?.length) {
const sources = finding.sources
.map((source) => `[${sourceLabel(source.title)}](<${source.url}>)`)
.join(", ");
lines.push("", `**Sources:** ${sources}`);
}
if (finding.suggestion) {
lines.push("", `**Suggestion:** ${stripStateMarkers(finding.suggestion)}`);
}
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._`,
);
return lines.join("\n");
}
/**
* What a no-longer-tracked inline comment is patched to when its thread has human
* replies (a bare one is deleted instead). Deliberately NEUTRAL: a finding leaves
* the inline set for many reasons that are not resolution — capped out, its line
* left the diff, aggregate-state truncation — so this text must never assert
* "resolved" or "dismissed". The marker stays, so a returning finding revives the
* same thread instead of opening a new one.
*/
export function inlineStubBody(tag: string, fp: string): string {
return [
inlineCommentMarker(tag, fp),
"_🤖 This finding is no longer tracked inline — see the main review comment for current status._",
].join("\n");
}
// @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
/** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
function setupNote(notes: string[] = []): string[] {
+50 -2
View File
@@ -3,6 +3,9 @@ import type { FeedbackRecord, Finding } from "./schema.js";
/** One human comment on the PR, as the reporter hands it over. */
export interface ReplyComment {
/** Issue-comment id, or the NEGATED PR-review-comment id for an inline reply.
* The two GitHub id sequences are independent, so negation keeps them
* collision-free in every stored/compared FeedbackRecord.commentId. */
id: number;
body: string;
login: string;
@@ -12,6 +15,15 @@ export interface ReplyComment {
* Absent/false is fail-closed — an unresolved author can never clear a finding. */
author?: boolean;
url?: string;
/**
* For an inline (PR review) reply: the fingerprint of OUR finding whose thread it
* answers, resolved by the reporter from `in_reply_to_id` chaining to an
* author-verified, marker-anchored inline comment of ours. A structural signal
* chosen by the replier (they replied to that thread), so it matches in EVERY
* `match` mode — but it never sets `citedId`: clearing still requires the
* replier's own unquoted `id:` token.
*/
threadFp?: string;
}
/** Blockquote lines read per comment. A reply that quotes half the review is
@@ -35,6 +47,23 @@ const FINDING_ID_RE = /\bid:([a-f0-9]{6,64})\b/gi;
*/
const OWN_COMMENT_RE = /<!--[^\n]*:(?:state|fingerprints)=/;
/** The tag-independent shape of an inline finding comment's identity marker. */
const INLINE_MARKER_RE = /<!--[^\n]*:inline:fp=/;
/**
* Tag-independent backstop for OUR inline finding comments, which carry an
* UNQUOTED `id:<fp>` token in the body (so a maintainer can copy it into a
* reply). If the author/tag filters ever miss one — a bot-login fallback, a
* commentTag rename, a second posting identity — matching it as a reply would
* let the review cite (and, with maintainer association, CLEAR) its own
* finding. Only UNQUOTED lines count: GitHub's "Quote reply" copies our marker
* verbatim behind `> `, and that comment is a genuine human reply that must
* still be matched.
*/
export function hasUnquotedInlineMarker(body: string): boolean {
return body.split("\n").some((line) => !/^\s*>/.test(line) && INLINE_MARKER_RE.test(line));
}
/**
* Markdown → comparable text: link text without the target, no backticks or
* emphasis marks, collapsed whitespace, lowercase, no trailing punctuation. A
@@ -82,6 +111,20 @@ export function extractQuotedLines(body: string): string[] {
return out;
}
/**
* Is `candidate` a more recent reply than `incumbent`? Issue comments carry
* positive ids; inline (PR review) replies carry NEGATED ids, so within the
* inline stream recency is the raw id's magnitude, not the signed value. The two
* sequences are independent, so cross-stream recency is undecidable — the issue
* comment wins deterministically (the main thread is the formal reply channel).
*/
export function replyIsNewer(candidate: number, incumbent: number): boolean {
if (candidate >= 0 === incumbent >= 0) {
return Math.abs(candidate) > Math.abs(incumbent);
}
return candidate >= 0;
}
/** The `id:<hex>` tokens a comment cites, lowercased and deduped. */
export function extractFindingIds(body: string): string[] {
const ids = [...body.matchAll(FINDING_ID_RE)].map((match) => match[1]!.toLowerCase());
@@ -137,7 +180,7 @@ export function matchReplies(
const newest = new Map<string, FeedbackRecord>();
for (const comment of comments) {
if (OWN_COMMENT_RE.test(comment.body)) {
if (OWN_COMMENT_RE.test(comment.body) || hasUnquotedInlineMarker(comment.body)) {
continue;
}
const matched = new Set<string>();
@@ -145,6 +188,11 @@ export function matchReplies(
// quote-matched reply still records WHETHER it cites the finding, because that is
// what decides clearing.
const cited = new Set(extractCitedFindingIds(comment.body));
// An inline reply names its finding structurally — the replier chose which
// thread to answer — so it matches in every `match` mode. Never sets `citedId`.
if (comment.threadFp && known.has(comment.threadFp)) {
matched.add(comment.threadFp);
}
if (opts.match !== "quote") {
for (const id of extractFindingIds(comment.body)) {
if (known.has(id)) {
@@ -168,7 +216,7 @@ export function matchReplies(
}
for (const fp of matched) {
const previous = newest.get(fp);
if (previous && previous.commentId >= comment.id) {
if (previous && !replyIsNewer(comment.id, previous.commentId)) {
continue;
}
newest.set(fp, {
+26
View File
@@ -137,6 +137,32 @@ export async function reviewInputHash(options: ReviewInputHashOptions): Promise<
return createHash("sha256").update(canonicalJson(input)).digest("hex");
}
/**
* Whether a run may reuse a cached result at all — the single definition of that
* policy, shared by the legacy and routed CI paths.
*
* It lives here because it was previously written out twice, once per path, and
* the copies drifted: when the offline index was removed, only the legacy copy
* dropped its research gate, so every routed repo silently kept running fresh
* reviews for a reason that no longer existed. Two expressions of one policy is
* the bug; one function that both paths call is the fix.
*
* Each flag means "this run has an input the cache key does not represent":
* dynamic stack context and model-backed reply adjudication both reach outside
* the scoped diff, and a maintainer's explicit /review is always a real rerun.
*/
export function reviewCacheAllowed(run: {
bypassTriggerGate: boolean;
/** Stack walking is on for this run. */
stack: boolean;
/** A feedback seam runs this pass (adjudication), not merely annotation. */
feedback: boolean;
/** PR metadata resolved; without it there is nothing stable to key on. */
hasMetadata: boolean;
}): boolean {
return !run.bypassTriggerGate && !run.stack && !run.feedback && run.hasMetadata;
}
/** Partial/failed reviews must be retried, never made durable by a cache hit. */
export function reviewCanBeReused(review: CoordinatorOutput): boolean {
return review.couldNotComplete !== true && review.incomplete.length === 0;
+10 -1
View File
@@ -428,7 +428,16 @@ export function applyPins(
pin.commentId === undefined ||
!records.some(
(record) =>
record.fp === pin.fp && record.maintainer === true && record.commentId > pin.commentId!,
record.fp === pin.fp &&
record.maintainer === true &&
// "Posted after the pin" is only decidable within one id sequence. Issue
// comments carry positive ids and inline (PR review) replies carry NEGATED
// ones (see the reporter's replyComments); the two sequences are
// independent, so a cross-stream comparison says nothing about time — keep
// the pin. Within a stream, recency is the raw id's magnitude (negation
// inverts the signed order for inline ids).
record.commentId >= 0 === pin.commentId! >= 0 &&
Math.abs(record.commentId) > Math.abs(pin.commentId!),
),
);
const pinnedFps = new Set(kept.map((pin) => pin.fp));
+724 -5
View File
@@ -4,12 +4,16 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { resolveTrustedTool, run } from "../core/exec.js";
import { errorMessage } from "../core/util.js";
import { parseUnifiedDiff } from "../core/diff.js";
import {
buildDiffLineIndex,
commentMarker,
inlineStubBody,
parseInlineMarkerFp,
parseReviewState,
renderAggregateMarkdown,
renderInlineCommentBody,
renderMarkdown,
} from "../core/render.js";
import type { LinkContext, ReviewState, ScopeReviewResult } from "../core/render.js";
@@ -17,7 +21,13 @@ import { matchReplies } from "../core/responses.js";
import type { ReplyComment } from "../core/responses.js";
import { dropStaleVerdict, feedbackApplied, feedbackNeedsPrAuthor } from "../core/adjudicate.js";
import type { AdjudicationItem } from "../core/adjudicate.js";
import { applyPins, collectPins, fingerprintFinding, scopedFingerprint } from "../core/schema.js";
import {
applyPins,
collectPins,
fingerprintFinding,
scopedFingerprint,
SEVERITY_RANK,
} from "../core/schema.js";
import type {
CoordinatorOutput,
DismissalRecord,
@@ -52,6 +62,13 @@ export interface GitHubReporterOptions {
* the reporter behaves exactly as before until a caller opts in.
*/
feedback?: LoadedConfig["feedback"];
/**
* Root-only inline-comment config (see LoadedConfig.inline). Absent ⇒ the
* reporter never touches PR review comments at all (zero extra API calls);
* present with `enabled: false` ⇒ no sync, but `clear()` still sweeps this
* tag's inline comments so a comment-mode switch or a disable cleans up.
*/
inline?: LoadedConfig["inline"];
/**
* The head commit this run reviews (the PR's immutable head OID, from the source's
* metadata). Feedback merging binds a stored verdict to the source it judged, so a
@@ -148,6 +165,188 @@ export interface IssueComment {
html_url?: string;
}
/** One PR review (inline/diff) comment, as the pulls comments API returns it. */
export interface ReviewCommentApi {
id: number;
body?: string;
path?: string;
line?: number | null;
user?: { login?: string };
html_url?: string;
/** Present on replies; points at the thread's root comment. */
in_reply_to_id?: number;
author_association?: string;
}
/**
* Burst-collapsing cache over the paginated review-comments listing, shared across
* reporter INSTANCES per PR (same motivation and key shape as prAuthorByPr): routed
* single-mode CI builds a fresh reporter per scope for its clear() reconciliation,
* and per-instance caching would re-fetch the identical list once per scope. The
* short TTL and mutation invalidation mirror fetchAllComments.
*/
const reviewCommentsByPr = new Map<string, { at: number; comments: Promise<ReviewCommentApi[]> }>();
/** An existing inline comment of OURS (author + anchored marker), as the planner sees it. */
export interface ExistingInlineComment {
id: number;
fp: string;
body: string;
url?: string;
/** Any listed comment replies to it (`in_reply_to_id` === its id). */
hasReplies: boolean;
}
/** What one inline sync run will do. Bodies are rendered by the executor. */
export interface InlineSyncPlan {
/** Findings to POST a new inline comment for (needs a commit id). */
create: Array<{ fp: string; finding: Finding }>;
/** Live threads whose body converges to the finding's current render. */
patch: Array<{ comment: ExistingInlineComment; finding: Finding }>;
/** No-longer-tracked threads WITH replies: patch to the neutral stub. */
stub: ExistingInlineComment[];
/** No-longer-tracked threads with no replies (and older duplicates): delete. */
remove: ExistingInlineComment[];
}
// @ref LLP 0011#suppression-is-never-silent [constrained-by] — leaving the inline set is
// not resolution: teardown only stubs/deletes when the caller proved the target set is
// complete, and the stub text (see inlineStubBody) never claims resolved/dismissed
/**
* Decide the inline mutations for one sync, purely. `targets` are the ACTIVE,
* diff-anchored findings; `existing` are OUR live inline comments.
*
* The cap is STICKY and deterministic: candidates order by severity rank then fp,
* and fps that already have a live thread fill slots before new ones are admitted —
* a live thread is never evicted (and its replies destroyed) just to create a
* different one, and a tie at the boundary cannot flip membership run to run.
*
* `teardown: false` (a failed/partial/carried run whose target set may be
* incomplete) makes the plan additive-only: no stubs, no deletes of live threads —
* except older DUPLICATES of a kept fp (a crash window between create and list),
* which are ours and redundant whatever the target set says.
*/
export function planInlineSync(opts: {
targets: Array<{ fp: string; finding: Finding }>;
existing: ExistingInlineComment[];
maxComments: number;
teardown: boolean;
}): InlineSyncPlan {
const ordered = [...opts.targets].sort(
(a, b) =>
SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] ||
a.fp.localeCompare(b.fp),
);
// Newest comment per fp is the live thread; older ones are duplicates.
const liveByFp = new Map<string, ExistingInlineComment>();
const duplicates: ExistingInlineComment[] = [];
for (const comment of [...opts.existing].sort((a, b) => a.id - b.id)) {
const previous = liveByFp.get(comment.fp);
if (previous) {
duplicates.push(previous);
}
liveByFp.set(comment.fp, comment);
}
const selected: Array<{ fp: string; finding: Finding }> = [];
for (const entry of ordered) {
if (selected.length >= opts.maxComments) {
break;
}
if (liveByFp.has(entry.fp)) {
selected.push(entry);
}
}
for (const entry of ordered) {
if (selected.length >= opts.maxComments) {
break;
}
if (!liveByFp.has(entry.fp)) {
selected.push(entry);
}
}
const plan: InlineSyncPlan = { create: [], patch: [], stub: [], remove: [] };
const selectedFps = new Set(selected.map((entry) => entry.fp));
for (const entry of selected) {
const live = liveByFp.get(entry.fp);
if (live) {
plan.patch.push({ comment: live, finding: entry.finding });
} else {
plan.create.push(entry);
}
}
for (const [fp, live] of liveByFp) {
if (selectedFps.has(fp)) {
continue;
}
if (!opts.teardown) {
continue;
}
if (live.hasReplies) {
plan.stub.push(live);
} else {
plan.remove.push(live);
}
}
// A duplicate with human replies is still a conversation: stub it like any other
// retired thread rather than deleting what people wrote in.
for (const duplicate of duplicates) {
if (duplicate.hasReplies) {
plan.stub.push(duplicate);
} else {
plan.remove.push(duplicate);
}
}
return plan;
}
/** `\r\n`-insensitive body comparison: the API returns CRLF-normalized bodies. */
export function inlineBodiesEqual(a: string, b: string): boolean {
return a.replace(/\r\n/g, "\n") === b.replace(/\r\n/g, "\n");
}
/** Does a body's UNQUOTED text carry this marker? Quoted (`> `) lines are the
* replier quoting US, which is a genuine reply, not our comment. */
export function hasUnquotedMarker(body: string, marker: string): boolean {
return body.split("\n").some((line) => !/^\s*>/.test(line) && line.includes(marker));
}
/**
* The findings the main comment renders in its ACTIVE list — not dismissed, not
* cleared by an applied reply, not requalified — which is exactly the inline sync's
* target set. Mirrors the isDropped/kept split in renderMarkdown and
* renderAggregateMarkdown (first record per fp wins, like matchedFeedback there).
* Exported for tests.
*/
export function activeFindings(
withFp: Array<{ finding: Finding; fp: string }>,
dismissed: DismissalRecord[],
records: FeedbackRecord[],
): Array<{ finding: Finding; fp: string }> {
const dismissedFps = new Set(dismissed.map((record) => record.fp));
const appliedFps = new Set<string>();
const seen = new Set<string>();
for (const record of records) {
if (!seen.has(record.fp)) {
seen.add(record.fp);
if (record.applied) {
appliedFps.add(record.fp);
}
}
}
return withFp.filter(
({ finding, fp }) => !dismissedFps.has(fp) && !appliedFps.has(fp) && !finding.requalifiedBy,
);
}
/** A per-render COPY of the link context carrying this run's inline thread URLs —
* the routed ci flow shares one LinkContext across reporters, so never mutate it. */
function withInlineUrls(link: LinkContext, inlineUrls: Map<string, string>): LinkContext {
return inlineUrls.size > 0 ? { ...link, inlineUrls } : link;
}
// @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged
/**
* Carry a decision already recorded in the prior comment state onto the freshly
@@ -490,16 +689,38 @@ export class GitHubReporter implements Reporter {
? applyPins(feedback, pinsIn)
: await this.computeFeedback(withFp, priorRecords, pinsIn);
const link = await this.linkContextAsync();
// Inline threads converge to the ACTIVE findings before the body renders, so
// the short form can link each one. A failed run (couldNotComplete) posts an
// empty findings list that proves nothing — additive-only, no teardown.
const inlineUrls = await this.syncInline(activeFindings(withFp, dismissed, records), link, {
teardown: review.couldNotComplete !== true,
});
await this.upsertComment(
renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins, inputHash),
renderMarkdown(
review,
this.options.commentTag,
dismissed,
withInlineUrls(link, inlineUrls),
records,
pins,
inputHash,
),
);
}
/** Post/update the aggregate multi-scope comment (comment:'single' mode). */
/**
* Post/update the aggregate multi-scope comment (comment:'single' mode).
* `opts.inlineTeardown: false` marks a run whose result set is NOT the complete
* truth about the PR — a `--scopes` partial run carries the other scopes out of
* (possibly truncated) embedded state — so the inline sync must not stub/delete
* threads it merely cannot see. Any failed scope forces the same, since its
* findings list is empty without being clean.
*/
async reportAggregate(
results: ScopeReviewResult[],
unmatchedFiles: string[],
feedback?: FeedbackRecord[],
opts?: { inlineTeardown?: boolean },
): Promise<void> {
const existing = await this.findExistingComment();
const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
@@ -518,12 +739,18 @@ export class GitHubReporter implements Reporter {
? applyPins(feedback, pinsIn)
: await this.computeFeedback(withFp, priorRecords, pinsIn);
const link = await this.linkContextAsync();
const teardown =
(opts?.inlineTeardown ?? true) &&
results.every((result) => result.review.couldNotComplete !== true);
const inlineUrls = await this.syncInline(activeFindings(withFp, dismissed, records), link, {
teardown,
});
await this.upsertComment(
renderAggregateMarkdown(
results,
this.options.commentTag,
dismissed,
link,
withInlineUrls(link, inlineUrls),
{ unmatchedFiles },
records,
pins,
@@ -698,10 +925,17 @@ export class GitHubReporter implements Reporter {
* failed lookup gives, and under any other `dismiss` value nothing reads the flag.
*/
private async replyComments(): Promise<ReplyComment[]> {
const [comments, ownLogin, prAuthor] = await Promise.all([
// Inline replies are read only when the reporter carries an ENABLED inline
// config: the `ecr feedback` crawl and every non-inline repo keep exactly the
// API footprint they had.
const inlineEnabled = this.options.inline?.enabled === true;
const [comments, ownLogin, prAuthor, reviewComments] = await Promise.all([
this.fetchAllComments(),
this.resolveOwnLogin(),
feedbackNeedsPrAuthor(this.options.feedback) ? this.resolvePrAuthor() : null,
inlineEnabled
? this.fetchAllReviewComments().catch(() => [] as ReviewCommentApi[])
: ([] as ReviewCommentApi[]),
]);
const out: ReplyComment[] = [];
for (const comment of comments) {
@@ -723,6 +957,64 @@ export class GitHubReporter implements Reporter {
...(comment.html_url ? { url: comment.html_url } : {}),
});
}
if (inlineEnabled && reviewComments.length > 0) {
// fp per OUR top-level inline comment (author + anchored marker), so a reply
// threading to it names its finding structurally.
const fpByRootId = new Map<number, string>();
if (ownLogin) {
for (const comment of reviewComments) {
if (comment.in_reply_to_id != null || comment.user?.login !== ownLogin) {
continue;
}
const fp = parseInlineMarkerFp(comment.body ?? "", this.options.commentTag);
if (fp) {
fpByRootId.set(comment.id, fp);
}
}
}
const byId = new Map(reviewComments.map((comment) => [comment.id, comment]));
// `in_reply_to_id` points at the thread root, but chase a short chain anyway
// so an unexpected reply-to-reply shape still resolves.
const threadFpOf = (comment: ReviewCommentApi): string | undefined => {
let parentId = comment.in_reply_to_id;
for (let hop = 0; parentId != null && hop < 5; hop++) {
const fp = fpByRootId.get(parentId);
if (fp) {
return fp;
}
parentId = byId.get(parentId)?.in_reply_to_id;
}
return undefined;
};
for (const comment of reviewComments) {
const login = comment.user?.login;
if (!login || (ownLogin && login === ownLogin)) {
continue;
}
const body = comment.body ?? "";
// Unquoted-line marker tests only: a GitHub "Quote reply" copies our marker
// behind `> ` and is a genuine human reply that must still be matched.
if (
hasUnquotedMarker(body, this.marker) ||
body.split("\n").some((line) => !/^\s*>/.test(line) && line.includes(":inline:fp="))
) {
continue;
}
const threadFp = threadFpOf(comment);
out.push({
// NEGATED: PR-review-comment ids are an independent sequence from issue
// comments; negation keeps stored commentIds collision-free across streams
// (and applyPins refuses cross-stream pin-lift comparisons).
id: -comment.id,
body,
login,
maintainer: MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""),
author: prAuthor != null && login === prAuthor,
...(comment.html_url ? { url: comment.html_url } : {}),
...(threadFp ? { threadFp } : {}),
});
}
}
return out;
}
@@ -748,6 +1040,35 @@ export class GitHubReporter implements Reporter {
for (const comment of await this.ownComments()) {
await this.deleteComment(comment.id);
}
// Sweep this tag's inline threads too, gated on the config KEY being present
// (not on `enabled`), so a comment-mode switch or a later disable still cleans
// up — while callers that never wire `inline` (break-glass reporter, terminal
// failure notes) keep their zero-review-API footprint. A thread humans replied
// in is stubbed, never deleted. Fail-soft per thread.
if (this.options.inline !== undefined) {
let own: ExistingInlineComment[] = [];
try {
own = await this.ownInlineComments();
} catch {
return; // fail-soft: the main-comment clear above already happened
}
for (const comment of own) {
try {
if (comment.hasReplies) {
const stub = inlineStubBody(this.options.commentTag, comment.fp);
if (!inlineBodiesEqual(comment.body, stub)) {
await this.patchReviewComment(comment.id, stub);
}
} else {
await this.deleteReviewComment(comment.id);
}
} catch (error) {
process.stderr.write(
`inline sync: clear failed for one inline comment (skipped): ${errorMessage(error)}\n`,
);
}
}
}
}
/**
@@ -793,6 +1114,365 @@ export class GitHubReporter implements Reporter {
return link;
}
/**
* Fetch ALL PR review (inline) comments, paginating manually like
* fetchAllCommentsUncached. Unlike the issue-comments endpoint this one honors
* sort/direction, but a full scan is needed anyway (we reconcile every own
* thread), so the same oldest-first walk keeps the two fetchers uniform.
*/
private async fetchAllReviewCommentsUncached(): Promise<ReviewCommentApi[]> {
const all: ReviewCommentApi[] = [];
const gh = await resolveTrustedTool("gh");
for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
const { stdout } = await run(
gh,
[
"api",
"-X",
"GET",
`repos/${this.options.repo}/pulls/${this.options.prNumber}/comments`,
"-f",
"per_page=100",
"-f",
`page=${page}`,
],
{ cwd: this.options.cwd },
);
let batch: ReviewCommentApi[];
try {
batch = JSON.parse(stdout) as ReviewCommentApi[];
} catch {
break;
}
if (!Array.isArray(batch) || batch.length === 0) {
break;
}
all.push(...batch);
if (batch.length < 100) {
break;
}
}
return all;
}
/** Shared-per-PR, TTL'd review-comments listing (see reviewCommentsByPr). */
private fetchAllReviewComments(): Promise<ReviewCommentApi[]> {
const key = prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd);
const now = Date.now();
const cached = reviewCommentsByPr.get(key);
if (cached && now - cached.at <= GitHubReporter.COMMENTS_CACHE_TTL_MS) {
return cached.comments;
}
const comments = this.fetchAllReviewCommentsUncached().catch((error: unknown) => {
// Never cache a failure.
if (reviewCommentsByPr.get(key)?.comments === comments) {
reviewCommentsByPr.delete(key);
}
throw error;
});
reviewCommentsByPr.set(key, { at: now, comments });
return comments;
}
private invalidateReviewComments(): void {
reviewCommentsByPr.delete(
prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd),
);
}
/**
* OUR live inline finding comments under THIS tag: top-level, authored by our
* own login (unspoofable), carrying the anchored byte-0 marker. Identity is
* author + marker exactly like selectOwnComments; an unresolved login returns
* nothing (fail closed). `hasReplies` is derived from the same listed snapshot.
*/
private async ownInlineComments(): Promise<ExistingInlineComment[]> {
const [comments, ownLogin] = await Promise.all([
this.fetchAllReviewComments(),
this.resolveOwnLogin(),
]);
if (!ownLogin) {
return [];
}
const repliedTo = new Set(
comments
.filter((comment) => comment.in_reply_to_id != null)
.map((comment) => comment.in_reply_to_id!),
);
const own: ExistingInlineComment[] = [];
for (const comment of comments) {
if (comment.in_reply_to_id != null || comment.user?.login !== ownLogin) {
continue;
}
const fp = parseInlineMarkerFp(comment.body ?? "", this.options.commentTag);
if (!fp) {
continue;
}
own.push({
id: comment.id,
fp,
body: comment.body ?? "",
...(comment.html_url ? { url: comment.html_url } : {}),
hasReplies: repliedTo.has(comment.id),
});
}
return own;
}
/** The PR's CURRENT head OID, fetched live — or null. Used to refuse creates
* whose commit_id (the head this run reviewed) is no longer the head. */
private async liveHeadOid(): Promise<string | null> {
try {
const gh = await resolveTrustedTool("gh");
const { stdout } = await run(
gh,
[
"pr",
"view",
String(this.options.prNumber),
"--repo",
this.options.repo,
"--json",
"headRefOid",
"--jq",
".headRefOid",
],
{ cwd: this.options.cwd },
);
return stdout.trim() || null;
} catch {
return null;
}
}
/** Secondary-rate-limit shapes (403/429): abort the rest of the sync, not just
* this mutation — hammering on would dig the hole deeper. */
private static isRateLimitError(error: unknown): boolean {
return /HTTP 403|HTTP 429|rate limit|secondary rate/i.test(errorMessage(error));
}
// @ref LLP 0008#github-reporter-identity [constrained-by] — inline threads are
// patched/deleted only when author + anchored marker both match; creates also require
// a resolved own login, or every run would duplicate what it cannot recognize
/**
* Converge this reporter's inline comments to `targets` (the ACTIVE, in-diff
* findings), returning fp → thread permalink for the live full-form threads.
* Never throws and never blocks the main comment: every failure degrades to
* "that finding renders full-form in the main comment".
*
* `teardown: false` marks a run whose target set may be INCOMPLETE — a failed
* review, a `--scopes` partial run, carried/truncated aggregate state — and makes
* the sync additive-only (see planInlineSync): a finding this run cannot see must
* not have its thread torn down.
*
* Creates require the reviewed head (`options.headSha`) to still BE the live head:
* after a mid-review push the diff we validated lines against no longer matches
* the commit we'd anchor to — a stale commit_id can silently pin the comment to
* the wrong code (or 422 wholesale after a force-push) — so creates are skipped
* and logged; patches/stubs/deletes are body-only and safe either way.
*/
private async syncInline(
targets: Array<{ finding: Finding; fp: string }>,
link: LinkContext,
opts: { teardown: boolean },
): Promise<Map<string, string>> {
const urls = new Map<string, string>();
const config = this.options.inline;
if (!config?.enabled || !link.diffLines) {
return urls;
}
const eligible = targets.filter(
({ finding }) =>
finding.line != null && link.diffLines!.get(finding.file)?.has(finding.line) === true,
);
let existing: ExistingInlineComment[];
try {
existing = await this.ownInlineComments();
if (eligible.length > 0 && (await this.resolveOwnLogin()) == null) {
// Without an own login we cannot recognize our previous comments, so a
// create now would duplicate on every future run. Fail closed.
process.stderr.write(
"inline sync: could not resolve the posting identity; skipping inline comments this run.\n",
);
return urls;
}
} catch (error) {
process.stderr.write(
`inline sync: could not list review comments (findings render in the main comment): ${errorMessage(error)}\n`,
);
return urls;
}
const plan = planInlineSync({
targets: eligible,
existing,
maxComments: config.maxComments,
teardown: opts.teardown,
});
// One live-head check per sync, only when creating. A mismatch (or an unknown
// reviewed head) skips creates; everything body-only still runs.
let creates = plan.create;
if (creates.length > 0) {
const reviewedHead = this.options.headSha;
const live = reviewedHead ? await this.liveHeadOid() : null;
if (!reviewedHead || live !== reviewedHead) {
process.stderr.write(
`inline sync: PR head ${reviewedHead ? "moved during the review" : "is unknown"}; ` +
`skipping ${creates.length} new inline comment(s) this run.\n`,
);
creates = [];
}
}
const counts = { created: 0, patched: 0, stubbed: 0, deleted: 0, failed: 0 };
let aborted = false;
const attempt = async (fn: () => Promise<void>): Promise<void> => {
if (aborted) {
return;
}
try {
await fn();
} catch (error) {
counts.failed++;
if (GitHubReporter.isRateLimitError(error)) {
aborted = true;
process.stderr.write(
`inline sync: rate-limited; aborting remaining inline mutations this run: ${errorMessage(error)}\n`,
);
} else {
process.stderr.write(`inline sync: mutation failed (skipped): ${errorMessage(error)}\n`);
}
}
};
// Sequential on purpose: parallel content creation is what trips GitHub's
// secondary rate limit.
for (const { comment, finding } of plan.patch) {
if (comment.url) {
urls.set(comment.fp, comment.url);
}
const body = renderInlineCommentBody(finding, this.options.commentTag, comment.fp);
if (inlineBodiesEqual(comment.body, body)) {
continue;
}
await attempt(async () => {
await this.patchReviewComment(comment.id, body);
counts.patched++;
});
}
for (const entry of creates) {
await attempt(async () => {
const created = await this.createReviewComment(
renderInlineCommentBody(entry.finding, this.options.commentTag, entry.fp),
entry.finding.file,
entry.finding.line!,
);
counts.created++;
if (created?.html_url) {
urls.set(entry.fp, created.html_url);
}
});
}
for (const comment of plan.stub) {
const body = inlineStubBody(this.options.commentTag, comment.fp);
if (inlineBodiesEqual(comment.body, body)) {
continue;
}
await attempt(async () => {
await this.patchReviewComment(comment.id, body);
counts.stubbed++;
});
}
for (const comment of plan.remove) {
await attempt(async () => {
await this.deleteReviewComment(comment.id);
counts.deleted++;
});
}
const total = counts.created + counts.patched + counts.stubbed + counts.deleted + counts.failed;
if (total > 0) {
const summary =
`inline sync: ${counts.created} created, ${counts.patched} patched, ` +
`${counts.stubbed} stubbed, ${counts.deleted} deleted, ${counts.failed} failed`;
process.stderr.write(`${summary}\n`);
// Inline comments are mutated in place like the main comment, so this line is
// the only per-run record of what changed (mirrors upsertComment's mirror).
await appendStepSummary(`🤖 AI review — ${summary}`);
}
return urls;
}
private async createReviewComment(
body: string,
path_: string,
line: number,
): Promise<{ html_url?: string } | null> {
this.invalidateReviewComments();
const gh = await resolveTrustedTool("gh");
const payload = JSON.stringify({
body,
commit_id: this.options.headSha,
path: path_,
line,
side: "RIGHT",
});
const dir = await mkdtemp(path.join(tmpdir(), "ecr-"));
const jsonPath = path.join(dir, "review-comment.json");
try {
await writeFile(jsonPath, payload, "utf8");
const { stdout } = await run(
gh,
[
"api",
"-X",
"POST",
`repos/${this.options.repo}/pulls/${this.options.prNumber}/comments`,
"--input",
jsonPath,
],
{ cwd: this.options.cwd },
);
try {
return JSON.parse(stdout) as { html_url?: string };
} catch {
return null;
}
} finally {
await rm(dir, { recursive: true, force: true });
}
}
private async patchReviewComment(commentId: number, body: string): Promise<void> {
this.invalidateReviewComments();
const gh = await resolveTrustedTool("gh");
await this.withBodyFile(body, (jsonPath) =>
run(
gh,
[
"api",
"-X",
"PATCH",
`repos/${this.options.repo}/pulls/comments/${commentId}`,
"--input",
jsonPath,
],
{ cwd: this.options.cwd },
),
);
}
private async deleteReviewComment(commentId: number): Promise<void> {
this.invalidateReviewComments();
const gh = await resolveTrustedTool("gh");
await run(
gh,
["api", "-X", "DELETE", `repos/${this.options.repo}/pulls/comments/${commentId}`],
{ cwd: this.options.cwd },
);
}
/**
* Add or remove per-PR finding dismissals in the reviewer's comment and re-render
* it in place — no re-review needed (the comment embeds the full review state). The
@@ -841,6 +1521,45 @@ export class GitHubReporter implements Reporter {
state.inputHash,
);
await this.patchComment(existing.id, body);
// Additive-only inline updates for exactly the fps this action named — never a
// full stale sweep: the embedded state an aggregate stores may be TRUNCATED
// (renderAggregateMarkdown trims kept findings to fit GitHub's body cap), so
// "not in state" here does not mean "gone" and must not tear a thread down.
// Fail-soft: a dismissal must never fail on the inline layer.
if (this.options.inline?.enabled) {
try {
const own = await this.ownInlineComments();
const byFp = new Map(own.map((comment) => [comment.fp, comment]));
const findingById = stateFindingsById(state);
for (const fp of next.matched) {
const comment = byFp.get(fp);
if (!comment) {
continue;
}
const stub = inlineStubBody(this.options.commentTag, fp);
if (!inlineBodiesEqual(comment.body, stub)) {
await this.patchReviewComment(comment.id, stub);
}
}
for (const fp of remove) {
const comment = byFp.get(fp);
const finding = findingById.get(fp);
// A finding truncated out of the state (or a thread that was deleted) just
// renders full-form in the main comment until the next review re-creates it.
if (!comment || !finding) {
continue;
}
const full = renderInlineCommentBody(finding, this.options.commentTag, fp);
if (!inlineBodiesEqual(comment.body, full)) {
await this.patchReviewComment(comment.id, full);
}
}
} catch (error) {
process.stderr.write(
`inline sync: dismissal update skipped (main comment already updated): ${errorMessage(error)}\n`,
);
}
}
return {
dismissedCount: next.dismissed.length,
matched: next.matched,
+18
View File
@@ -22,17 +22,26 @@ const DIRECT_PROVIDER_ORDER: readonly ProviderId[] = [
"android-releases",
"media3",
"agp",
"android-ndk",
"android",
"glide",
"okhttp",
"kotlin-coroutines",
"gradle",
"jetbrains-issues",
"react",
"web-standards",
"chrome-devtools-protocol",
"flow",
"typescript",
"cmake",
"cocoapods",
"react-native-reanimated",
"react-native-gesture-handler",
"react-native-screens",
"react-native-worklets",
"react-native",
"metro",
"expo",
];
@@ -49,13 +58,22 @@ const DIRECT_SOURCE_KIND: Record<ProviderId, SourceKind> = {
"kotlin-coroutines": "official-guide",
gradle: "official-guide",
agp: "release-notes",
"android-ndk": "official-guide",
"jetbrains-issues": "issue-tracker",
expo: "official-api",
react: "official-api",
"react-native": "official-api",
"web-standards": "standard",
"chrome-devtools-protocol": "standard",
flow: "official-guide",
typescript: "official-guide",
cmake: "official-guide",
cocoapods: "official-guide",
"react-native-reanimated": "official-guide",
"react-native-gesture-handler": "official-guide",
"react-native-screens": "official-guide",
"react-native-worklets": "official-guide",
metro: "official-guide",
};
export interface DirectDocumentationTarget {
+77
View File
@@ -345,6 +345,10 @@ export const expoProvider = htmlProvider("expo", "react-native", "Expo documenta
{ hostname: "docs.expo.dev", prefixes: [""] },
]);
export const reactProvider = htmlProvider("react", "react-native", "React documentation", [
{ hostname: "react.dev", prefixes: ["/reference/react", "/reference/rules"] },
]);
export const reactNativeProvider = htmlProvider(
"react-native",
"react-native",
@@ -357,6 +361,66 @@ export const reactNativeProvider = htmlProvider(
],
);
export const webStandardsProvider = htmlProvider(
"web-standards",
"react-native",
"Web platform standards",
[
{ hostname: "fetch.spec.whatwg.org", prefixes: [""] },
{ hostname: "xhr.spec.whatwg.org", prefixes: [""] },
{ hostname: "websockets.spec.whatwg.org", prefixes: [""] },
{ hostname: "dom.spec.whatwg.org", prefixes: [""] },
{ hostname: "streams.spec.whatwg.org", prefixes: [""] },
{ hostname: "url.spec.whatwg.org", prefixes: [""] },
{ hostname: "encoding.spec.whatwg.org", prefixes: [""] },
{ hostname: "console.spec.whatwg.org", prefixes: [""] },
{ hostname: "html.spec.whatwg.org", prefixes: ["/multipage"] },
{
hostname: "w3c.github.io",
prefixes: [
"/FileAPI",
"/hr-time",
"/performance-timeline",
"/resource-timing",
"/IntersectionObserver",
],
},
],
);
export const chromeDevtoolsProtocolProvider = htmlProvider(
"chrome-devtools-protocol",
"react-native",
"Chrome DevTools Protocol documentation",
[{ hostname: "chromedevtools.github.io", prefixes: ["/devtools-protocol"] }],
);
export const flowProvider = htmlProvider("flow", "react-native", "Flow documentation", [
{ hostname: "flow.org", prefixes: ["/en/docs"] },
]);
export const typescriptProvider = htmlProvider(
"typescript",
"react-native",
"TypeScript documentation",
[{ hostname: "www.typescriptlang.org", prefixes: ["/docs"] }],
);
export const androidNdkProvider = htmlProvider(
"android-ndk",
"android",
"Android NDK documentation",
[{ hostname: "developer.android.com", prefixes: ["/ndk"] }],
);
export const cmakeProvider = htmlProvider("cmake", "android", "CMake documentation", [
{ hostname: "cmake.org", prefixes: ["/cmake/help"] },
]);
export const cocoaPodsProvider = htmlProvider("cocoapods", "apple", "CocoaPods guides", [
{ hostname: "guides.cocoapods.org", prefixes: ["/syntax", "/using"] },
]);
export const reactNativeReanimatedProvider = htmlProvider(
"react-native-reanimated",
"react-native",
@@ -431,6 +495,10 @@ export const reactNativeWorkletsProvider = htmlProvider(
true,
);
export const metroProvider = htmlProvider("metro", "react-native", "Metro bundler documentation", [
{ hostname: "metrobundler.dev", prefixes: [""] },
]);
const providers: Record<ProviderId, DocumentationProvider> = {
apple: appleProvider,
"apple-releases": appleReleasesProvider,
@@ -446,11 +514,20 @@ const providers: Record<ProviderId, DocumentationProvider> = {
agp: agpProvider,
"jetbrains-issues": jetbrainsIssuesProvider,
expo: expoProvider,
react: reactProvider,
"react-native": reactNativeProvider,
"web-standards": webStandardsProvider,
"chrome-devtools-protocol": chromeDevtoolsProtocolProvider,
flow: flowProvider,
typescript: typescriptProvider,
"android-ndk": androidNdkProvider,
cmake: cmakeProvider,
cocoapods: cocoaPodsProvider,
"react-native-reanimated": reactNativeReanimatedProvider,
"react-native-gesture-handler": reactNativeGestureHandlerProvider,
"react-native-screens": reactNativeScreensProvider,
"react-native-worklets": reactNativeWorkletsProvider,
metro: metroProvider,
};
export function getProvider(provider: ProviderId): DocumentationProvider {
+36
View File
@@ -71,10 +71,42 @@ const providerSearchDefinitions: Record<Exclude<ProviderId, "expo">, ProviderSea
scopes: ["youtrack.jetbrains.com/issue"],
sourceKind: "issue-tracker",
},
react: {
scopes: ["react.dev/reference/react", "react.dev/reference/rules"],
sourceKind: "official-api",
},
"react-native": {
scopes: ["reactnative.dev"],
sourceKind: "official-api",
},
"web-standards": {
scopes: ["whatwg.org", "w3c.github.io"],
sourceKind: "standard",
},
"chrome-devtools-protocol": {
scopes: ["chromedevtools.github.io/devtools-protocol"],
sourceKind: "standard",
},
flow: {
scopes: ["flow.org/en/docs"],
sourceKind: "official-guide",
},
typescript: {
scopes: ["typescriptlang.org/docs"],
sourceKind: "official-guide",
},
"android-ndk": {
scopes: ["developer.android.com/ndk"],
sourceKind: "official-guide",
},
cmake: {
scopes: ["cmake.org/cmake/help/latest"],
sourceKind: "official-guide",
},
cocoapods: {
scopes: ["guides.cocoapods.org/syntax", "guides.cocoapods.org/using"],
sourceKind: "official-guide",
},
"react-native-reanimated": {
scopes: ["docs.swmansion.com/react-native-reanimated/docs"],
sourceKind: "official-api",
@@ -91,6 +123,10 @@ const providerSearchDefinitions: Record<Exclude<ProviderId, "expo">, ProviderSea
scopes: ["docs.swmansion.com/react-native-worklets/docs"],
sourceKind: "official-api",
},
metro: {
scopes: ["metrobundler.dev/docs"],
sourceKind: "official-guide",
},
};
export interface RemoteSearchOptions {
+4 -2
View File
@@ -25,7 +25,7 @@ const queryGuidance =
"Formulate short documentation queries from exact API symbols plus one behavior or constraint term. Good: `CameraView barcodeScannerSettings`, `NWPathMonitor pathUpdateHandler`, `GestureDetector simultaneous gestures`. Avoid questions, prose, package/import names, code snippets, literals, paths, credentials, and other sensitive context. If the first result is broad, retry with a narrower symbol or member name.";
const providerGuidance =
"Provider map: apple=Apple SDK APIs and Human Interface Guidelines; apple-releases=Xcode and Apple platform release notes; swift-evolution=Swift Evolution proposals; sdwebimage=SDWebImage APIs and caching/loading behavior; android=Android, Jetpack, Compose, and Google Play services APIs; android-releases=Android platform releases and behavior changes; media3=Jetpack Media3; glide=Glide; okhttp=OkHttp; kotlin-coroutines=Kotlin coroutines; gradle=Gradle; agp=Android Gradle Plugin; jetbrains-issues=JetBrains YouTrack context; expo=Expo documentation; react-native=React Native core; react-native-reanimated=Reanimated; react-native-gesture-handler=Gesture Handler; react-native-screens=Screens; react-native-worklets=Worklets. Native source retains platform context: use apple/android for OS contracts and add the dependency provider for dependency-owned behavior; an Expo package path does not make a native API an Expo-docs query. Issue-tracker results are context, not API contracts.";
"Provider map: apple=Apple SDK APIs and Human Interface Guidelines; apple-releases=Xcode and Apple platform release notes; swift-evolution=Swift Evolution proposals; sdwebimage=SDWebImage APIs and caching/loading behavior; android=Android, Jetpack, Compose, and Google Play services APIs; android-releases=Android platform releases and behavior changes; android-ndk=Android NDK; media3=Jetpack Media3; glide=Glide; okhttp=OkHttp; kotlin-coroutines=Kotlin coroutines; gradle=Gradle; agp=Android Gradle Plugin; jetbrains-issues=JetBrains YouTrack context; expo=Expo documentation; react=React APIs and semantics; react-native=React Native core documentation; web-standards=WHATWG and W3C web platform standards; chrome-devtools-protocol=Chrome DevTools Protocol; flow=Flow type-system documentation; typescript=TypeScript declaration semantics; cmake=CMake build-system documentation; cocoapods=CocoaPods integration guides; react-native-reanimated=Reanimated; react-native-gesture-handler=Gesture Handler; react-native-screens=Screens; react-native-worklets=Worklets; metro=Metro bundler. Native source retains platform context: use apple/android for OS contracts and add the dependency, build-tool, type-system, or web-contract provider for behavior that source owns; repository ownership does not make a native API a React Native or Expo documentation query. Standards describe compatibility targets; confirm the reviewed implementation claims that target before applying them. Issue-tracker results are context, not API contracts.";
export interface DocumentationServerOptions {
braveApiKey?: string;
@@ -84,7 +84,9 @@ export async function createDocumentationServer(options: DocumentationServerOpti
.min(1)
.max(SOURCE_KINDS.length)
.optional()
.describe("Optional provenance classes to search"),
.describe(
"Optional provenance classes to search; standard denotes an external compatibility specification rather than an implementation-specific API page",
),
},
annotations: {
readOnlyHint: true,
+10
View File
@@ -18,11 +18,20 @@ export const PROVIDERS = [
"agp",
"jetbrains-issues",
"expo",
"react",
"react-native",
"web-standards",
"chrome-devtools-protocol",
"flow",
"typescript",
"android-ndk",
"cmake",
"cocoapods",
"react-native-reanimated",
"react-native-gesture-handler",
"react-native-screens",
"react-native-worklets",
"metro",
] as const;
export type ProviderId = (typeof PROVIDERS)[number];
@@ -30,6 +39,7 @@ export type ProviderId = (typeof PROVIDERS)[number];
export const SOURCE_KINDS = [
"official-api",
"official-guide",
"standard",
"release-notes",
"issue-tracker",
] as const;
+2
View File
@@ -110,6 +110,8 @@ jobs:
# Anthropic review credential — the env var named by auth.tokenEnv in
# config.jsonc (see workflow.yml for the accepted token shapes).
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
# Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY):
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional: override the model for every agent.
+2
View File
@@ -148,6 +148,8 @@ jobs:
# by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude
# Code CLI reads either).
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
# Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY):
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional: override the model for every agent.
+23
View File
@@ -86,6 +86,13 @@
// store the key as a repo secret and pass it under that env var.
// "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" }
//
// Muse Spark via the public Meta Model API Responses endpoint:
// set models to `meta/muse-spark-1.2` and configure:
// "auth": { "providers": {
// "meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" }
// } }
// Map META_API_KEY in the workflows and set ECR_EXPECTED_TOKEN_ENV to match.
//
// ChatGPT/Codex subscription ("oauth"): tokenEnv holds the ACCESS token from
// an `opencode auth login` ChatGPT sign-in — `ecr setup-auth` extracts it.
// NEVER share the refresh token: it is single-use and dies on first rotation.
@@ -153,4 +160,20 @@
// "protectedCategories": ["secrets", "security"],
// "maxAdjudications": 10 // cap on model calls per run
// }
// Inline PR comments (ROOT-ONLY, off by default). When enabled, each finding
// anchored to a line in the PR diff is posted as an inline review comment on
// that line (full rationale/suggestion there), and the main comment renders it
// short — title, links, a one-line rationale — with a link to the thread. The
// main comment stays the single source of truth (state, /dismiss, replies).
// Threads update in place across re-reviews; a retired thread with human
// replies is stubbed, never deleted. Replies on inline threads are matched to
// their finding like main-comment replies (same clearing rules).
// NOTE: the first enabled run on a PR sends one notification per created
// inline comment — `maxComments` bounds that. Disabling later leaves existing
// threads up until a comment-mode switch (or manual cleanup) clears them.
// "inline": {
// "enabled": false,
// "maxComments": 20 // per PR (single comment mode) / per scope (per-scope mode)
// }
}
+2
View File
@@ -114,6 +114,8 @@ jobs:
# by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude
# Code CLI reads either).
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
# Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY):
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional: override the model for every agent.