diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c009dff3..c413a556 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -39,8 +39,6 @@ body:
label: Installation Method
options:
- npm (global)
- - Homebrew
- - MacPorts
- GitHub Release binary
- Built from source
validations:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 23a1067f..c0fb2a3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,16 +3,8 @@ name: CI
on:
push:
branches: [main]
- paths-ignore:
- - '**.md'
- - '**/LICENSE'
- - '**/.gitignore'
pull_request:
branches: [main]
- paths-ignore:
- - '**.md'
- - '**/LICENSE'
- - '**/.gitignore'
permissions:
contents: read
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 4c3b3c15..204c1014 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -5,9 +5,6 @@ on:
branches: [main]
paths:
- 'pages/**'
- - 'install.sh'
- - 'install.ps1'
- - '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions:
@@ -45,16 +42,6 @@ jobs:
mkdir -p _site
cp -r pages/dist/* _site/
cp pages/logo.svg _site/logo.svg
- cp install.sh _site/install.sh
- cp install.ps1 _site/install.ps1
-
- - name: Verify install scripts
- run: |
- test -f _site/install.sh
- test -f _site/install.ps1
- sh -n _site/install.sh
- cmp install.sh _site/install.sh
- cmp install.ps1 _site/install.ps1
- uses: actions/upload-pages-artifact@v5
with:
diff --git a/README.ja-JP.md b/README.ja-JP.md
index 8038b18e..83d05b06 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -50,8 +50,6 @@ Gitのdiffを読み取り、変更されたファイルをツール利用機能
実際のコードレビューに基づくベンチマーク。**50**の人気オープンソースリポジトリから**200**の実際のPull Requestを厳選し、**10**のプログラミング言語をカバー——80人以上のシニアエンジニアによるクロスバリデーション(**1,505**件のアノテーション済み欠陥)。
- Hugging FaceでAACR-Benchデータセットを見る。
-
| 指標 | 測定内容 | 重要性 |
|------|----------|--------|
| **F1** | 精度とリコールの調和平均 | レビュー品質を示す最良の単一指標 |
diff --git a/README.ko-KR.md b/README.ko-KR.md
index 7baa8fa8..bb90e69b 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -50,8 +50,6 @@ Open Code Review는 AI 기반 코드 리뷰 CLI 도구입니다. Alibaba Group
실제 코드 리뷰 기반 벤치마크. **50**개 인기 오픈소스 저장소에서 **200**개 실제 Pull Request를 엄선하고, **10**개 프로그래밍 언어를 커버 — 80명 이상의 시니어 엔지니어가 교차 검증(**1,505**개 어노테이션된 결함).
- Hugging Face에서 AACR-Bench 데이터셋 살펴보기.
-
| 지표 | 측정 내용 | 중요한 이유 |
|------|-----------|-------------|
| **F1** | 정밀도와 재현율의 조화 평균 | 리뷰 품질을 나타내는 최적의 단일 지표 |
diff --git a/README.md b/README.md
index 24dbd64d..452e0221 100644
--- a/README.md
+++ b/README.md
@@ -50,8 +50,6 @@ Visit the [official website](https://open-codereview.ai) for more details.
A real-world code review benchmark built from **50** popular open-source repositories, **200** real Pull Requests, and **10** programming languages — cross-validated by 80+ senior engineers (**1,505** annotated ground-truth issues).
- Explore the AACR-Bench dataset on Hugging Face.
-
| Metric | What it measures | Why it matters |
|--------|-----------------|----------------|
| **F1** | Harmonic mean of precision and recall | Best single number for overall review quality |
diff --git a/README.ru-RU.md b/README.ru-RU.md
index 821e4f48..8fa4fb80 100644
--- a/README.ru-RU.md
+++ b/README.ru-RU.md
@@ -50,8 +50,6 @@ Open Code Review — это CLI-инструмент для код-ревью н
Бенчмарк собран на основе реальных код-ревью и включает **50** популярных open-source-репозиториев, **200** реальных пул-реквестов и **10** языков программирования. Разметку из **1 505** дефектов перекрёстно проверили более 80 ведущих инженеров.
- Открыть датасет AACR-Bench на Hugging Face.
-
| Метрика | Что измеряет | Почему важна |
|---------|-------------|--------------|
| **F1** | Гармоническое среднее precision и recall | Лучший единый показатель качества ревью |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8e6d7ef5..93a2df4d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -50,8 +50,6 @@ Open Code Review 是一款 AI 驱动的代码审查 CLI 工具。它的前身是
基于真实场景的代码审查基准测试,从 **50** 个热门开源仓库中精选 **200** 个真实的 Pull Request,覆盖 **10** 种编程语言——由 80+ 位资深工程师交叉标注验证(共 **1,505** 个标注缺陷)。
- 在 Hugging Face 上探索 AACR-Bench 数据集。
-
| 指标 | 含义 | 为什么重要 |
|------|------|-----------|
| **F1** | 准确率与召回率的调和均值 | 综合衡量审查质量的最佳单一指标 |
diff --git a/cmd/opencodereview/compat_test.go b/cmd/opencodereview/compat_test.go
index c14a17af..462a8ca5 100644
--- a/cmd/opencodereview/compat_test.go
+++ b/cmd/opencodereview/compat_test.go
@@ -3,15 +3,7 @@
package main
-import (
- "context"
-
- "github.com/spf13/cobra"
-)
-
-func executeReview(opts reviewOptions) error {
- return executeReviewContext(context.Background(), opts)
-}
+import "github.com/spf13/cobra"
// parseReviewFlags provides test compatibility: parses args through a fresh
// cobra command instance and returns the resulting reviewOptions.
diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go
index 1d1e8dd2..7b2eae12 100644
--- a/cmd/opencodereview/review_cmd.go
+++ b/cmd/opencodereview/review_cmd.go
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"os"
- "os/signal"
"path/filepath"
"sort"
"strings"
@@ -47,8 +46,9 @@ type reviewOptions struct {
maxGitProcs int
maxTokens int
maxTokensBudget int
- noFilter bool
- preview bool
+ noFilter bool
+ preview bool
+ resumeIncremental bool
}
var reviewOpts reviewOptions
@@ -97,9 +97,7 @@ var reviewCmd = &cobra.Command{
if err := validateReviewOptions(&reviewOpts); err != nil {
return err
}
- ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
- defer stop()
- return executeReviewContext(ctx, reviewOpts)
+ return executeReview(reviewOpts)
},
}
@@ -107,7 +105,7 @@ func init() {
registerReviewFlags(reviewCmd, &reviewOpts)
}
-func executeReviewContext(ctx context.Context, opts reviewOptions) error {
+func executeReview(opts reviewOptions) error {
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs, true)
if err != nil {
return err
@@ -140,7 +138,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
}
if opts.preview {
- return runPreviewContext(ctx, cc, opts)
+ return runPreview(cc, opts)
}
resumeState, err := loadReviewResumeState(cc.RepoDir, opts)
@@ -165,7 +163,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
// Strictly before agent.New, so a rejected resume persists nothing. The sealed
// input it returns pins the run to the very commits this check passed on, so
// the decision cannot be undone by a ref moving afterwards.
- sealed, err := validateResumeIdentity(ctx, cc, opts, rt, resumeState)
+ sealed, err := validateResumeIdentity(context.Background(), cc, opts, rt, resumeState)
if err != nil {
return err
}
@@ -189,7 +187,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
}
tools := buildToolRegistry(rt.Collector, fileReader)
- mcpClients := initMCPClients(ctx, rt.AppCfg, tools, cc.RepoDir, Version)
+ mcpClients := initMCPClients(context.Background(), rt.AppCfg, tools, cc.RepoDir, Version)
defer func() {
for _, mc := range mcpClients {
if err := mc.Close(); err != nil {
@@ -235,7 +233,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
q := newQuietHandle(opts.outputFormat, opts.audience)
defer q.Restore()
- runCtx, span := telemetry.StartSpan(telemetry.ContextWithTraceParentFromEnv(ctx), "review.run")
+ ctx, span := telemetry.StartSpan(telemetry.ContextWithTraceParentFromEnv(context.Background()), "review.run")
defer span.End()
telemetry.SetAttr(span, "review.repo", cc.RepoDir)
telemetry.SetAttr(span, "review.from", opts.from)
@@ -250,7 +248,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
}
startTime := time.Now()
- comments, runErr := ag.Run(runCtx)
+ comments, runErr := ag.Run(ctx)
manifest := ag.RunManifest()
// Freeze the retry report at the same boundary as the manifest: ag.Run has
@@ -280,7 +278,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error {
var emitErr error
emitted := manifest != nil || runErr == nil
if emitted {
- emitErr = emitRunResult(runCtx, ag, comments, startTime, opts.outputFormat, opts.audience, q, llmIdentity, retryReport)
+ emitErr = emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q, llmIdentity, retryReport)
if emitErr != nil {
emitErr = fmt.Errorf("emit review result: %w", emitErr)
}
@@ -396,6 +394,9 @@ func validateResumeIdentity(ctx context.Context, cc *commonContext, opts reviewO
Model: rt.Model,
ProviderExplicit: opts.provider != "",
ModelExplicit: opts.model != "",
+ Incremental: opts.resumeIncremental,
+ ResolvedBase: sealed.Resolution.ResolvedBase,
+ PerFileMaxTokens: cc.Template.MaxTokens,
}); err != nil {
return nil, err
}
@@ -481,8 +482,8 @@ func validateReviewRefs(repoDir string, opts reviewOptions) error {
return nil
}
-func runPreviewContext(ctx context.Context, cc *commonContext, opts reviewOptions) error {
- preview, err := agent.Preview(ctx, agent.Args{
+func runPreview(cc *commonContext, opts reviewOptions) error {
+ preview, err := agent.Preview(context.Background(), agent.Args{
RepoDir: cc.RepoDir,
From: opts.from,
To: opts.to,
diff --git a/cmd/opencodereview/review_helpers_test.go b/cmd/opencodereview/review_helpers_test.go
index c9feac55..4364f911 100644
--- a/cmd/opencodereview/review_helpers_test.go
+++ b/cmd/opencodereview/review_helpers_test.go
@@ -11,10 +11,6 @@ import (
"github.com/alibaba/open-code-review/internal/tool"
)
-func runPreview(cc *commonContext, opts reviewOptions) error {
- return runPreviewContext(context.Background(), cc, opts)
-}
-
func TestRunPreview(t *testing.T) {
dir := initTestGitRepo(t)
gitCommitFile(t, dir, "x.go", "package x\n", "add x")
diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go
index d52d4cde..bfa89989 100644
--- a/cmd/opencodereview/shared.go
+++ b/cmd/opencodereview/shared.go
@@ -403,17 +403,9 @@ func emitRunResult(
}
if !machineReadable {
- telemetry.PrintTraceSummary(telemetry.TraceSummary{
- FilesReviewed: ag.FilesReviewed(),
- CommentsGenerated: int64(len(comments)),
- InputTokens: ag.TotalInputTokens(),
- OutputTokens: ag.TotalOutputTokens(),
- TotalTokens: ag.TotalTokensUsed(),
- CacheReadTokens: ag.TotalCacheReadTokens(),
- CacheWriteTokens: ag.TotalCacheWriteTokens(),
- Duration: duration,
- SessionID: ag.SessionID(),
- })
+ telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)),
+ ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
+ ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
}
if outputFormat == "json" {
diff --git a/cmd/opencodereview/shared_flags.go b/cmd/opencodereview/shared_flags.go
index 6698b627..f60981fd 100644
--- a/cmd/opencodereview/shared_flags.go
+++ b/cmd/opencodereview/shared_flags.go
@@ -109,6 +109,15 @@ func validateReviewOptions(opts *reviewOptions) error {
if opts.preview && opts.resume != "" {
return fmt.Errorf("--preview and --resume cannot be used together")
}
+ if opts.preview && opts.resumeIncremental {
+ return fmt.Errorf("--preview and --resume-incremental cannot be used together")
+ }
+ if opts.resumeIncremental && opts.resume == "" {
+ return fmt.Errorf("--resume-incremental requires --resume ")
+ }
+ if opts.resumeIncremental && (opts.from == "" || opts.to == "") {
+ return fmt.Errorf("--resume-incremental is only supported in range mode (--from and --to)")
+ }
if err := validateAudience(opts.audience); err != nil {
return err
}
@@ -172,6 +181,7 @@ func registerReviewFlags(cmd *cobra.Command, opts *reviewOptions) {
addDiffFlags(cmd, &opts.from, &opts.to, &opts.commit)
cmd.Flags().StringVar(&opts.resume, "resume", "", "resume from a previous review session id")
cmd.RegisterFlagCompletionFunc("resume", completeSessionIDs)
+ cmd.Flags().BoolVar(&opts.resumeIncremental, "resume-incremental", false, "allow resuming range mode when the head commit has moved (reuses unchanged file reviews)")
addExcludeFlag(cmd, &opts.excludes)
addOutputFlags(cmd, &opts.outputFormat, &opts.audience)
addConcurrencyFlags(cmd, &opts.concurrency, &opts.perFileTimeout, &opts.maxTools, &opts.maxGitProcs, &opts.maxTokens, &opts.maxTokensBudget)
diff --git a/install.ps1 b/install.ps1
index d042d64a..e74d7e89 100644
--- a/install.ps1
+++ b/install.ps1
@@ -1,7 +1,7 @@
# Install the ocr (Open Code Review) CLI from GitHub releases on Windows.
-# irm https://open-codereview.ai/install.ps1 | iex
+# irm https://raw.githubusercontent.com/alibaba/open-code-review/main/install.ps1 | iex
# Prefer to inspect first:
-# irm https://open-codereview.ai/install.ps1 -OutFile install.ps1
+# irm https://raw.githubusercontent.com/alibaba/open-code-review/main/install.ps1 -OutFile install.ps1
# notepad install.ps1 # review, then: .\install.ps1
# Env: OCR_INSTALL_DIR (default $env:LOCALAPPDATA\Programs\ocr), OCR_VERSION (default latest).
# Requires PowerShell 5.1+ or PowerShell 7+.
diff --git a/install.sh b/install.sh
index 8f92a5fa..c85330a7 100755
--- a/install.sh
+++ b/install.sh
@@ -4,9 +4,9 @@
# Copyright 2026 alibaba/open-code-review Contributors
# Install the ocr (Open Code Review) CLI from GitHub releases.
-# curl -fsSL https://open-codereview.ai/install.sh | sh
+# curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh
# Prefer to inspect first:
-# curl -fsSL https://open-codereview.ai/install.sh -o install.sh
+# curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh -o install.sh
# less install.sh && sh install.sh
# Env: OCR_INSTALL_DIR (default /usr/local/bin), OCR_VERSION (default latest).
set -eu
@@ -23,7 +23,7 @@ main() {
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$os" in
darwin|linux) ;;
- *) err "unsupported OS: $os (on Windows use: irm https://open-codereview.ai/install.ps1 | iex)" ;;
+ *) err "unsupported OS: $os (on Windows use: irm https://raw.githubusercontent.com/alibaba/open-code-review/main/install.ps1 | iex)" ;;
esac
arch="$(uname -m)"
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index c22369e2..0bec165d 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -35,7 +35,6 @@ import (
"github.com/alibaba/open-code-review/internal/telemetry"
"github.com/alibaba/open-code-review/internal/tool"
- "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
@@ -242,7 +241,6 @@ func New(args Args) *Agent {
CommentWorkerPool: args.CommentWorkerPool,
Session: args.Session,
DiffLookup: a.findDiff,
- AllDiffs: a.allDiffs,
// Non-nil only here: the same Runner serves scan, whose requests must
// stay out of the retry report. See newRequestMeta.
NewRequestMeta: a.newRequestMeta,
@@ -608,7 +606,6 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute
var dispatched int64
-dispatchLoop:
for i := range toDispatch {
if toDispatch[i].IsDeleted {
continue
@@ -658,17 +655,9 @@ dispatchLoop:
}
}
- select {
- case sem <- struct{}{}: // acquire semaphore
- case <-ctx.Done():
- break dispatchLoop
- }
- if ctx.Err() != nil {
- <-sem // release the slot acquired concurrently with cancellation
- break dispatchLoop
- }
dispatched++
wg.Add(1)
+ sem <- struct{}{} // acquire semaphore
go func(d model.Diff) {
fingerprint := reviewItemFingerprint(a.reviewMode(), d)
@@ -742,19 +731,16 @@ dispatchLoop:
}
wg.Wait()
- // All subtasks finished — collect comments from the global collector once.
- if a.args.CommentWorkerPool != nil {
- a.args.CommentWorkerPool.Await()
- }
- if ctxErr := ctx.Err(); ctxErr != nil {
- a.recordContextFailure(ctxErr)
- return a.args.CommentCollector.Comments(), ctxErr
- }
if dispatched == 0 {
return a.args.CommentCollector.Comments(), nil
}
+ // All subtasks finished — collect comments from the global collector once.
+ if a.args.CommentWorkerPool != nil {
+ a.args.CommentWorkerPool.Await()
+ }
+
failed := atomic.LoadInt64(&a.subtaskFailed)
reused := int64(0)
if a.resumeInfo != nil {
@@ -770,22 +756,6 @@ dispatchLoop:
return a.args.CommentCollector.Comments(), nil
}
-func (a *Agent) recordContextFailure(err error) {
- if b := a.session.Manifest(); b != nil {
- var setErr error
- if errors.Is(err, context.DeadlineExceeded) {
- // A deadline truncates pending coverage without overriding completed items.
- setErr = b.SetPendingFailureCause(session.FailureTimeout, "review deadline exceeded")
- } else {
- // Explicit cancellation stops the run itself, not just its pending items.
- setErr = b.SetRunFailure(session.RunFailureCancelled, "review was cancelled")
- }
- if setErr != nil {
- a.recordWarning("manifest_error", "", setErr.Error())
- }
- }
-}
-
func (a *Agent) applyResume(diffs []model.Diff) []model.Diff {
resume := a.args.Resume
if resume == nil {
@@ -877,6 +847,7 @@ func (a *Agent) initManifest() {
ConfiguredConcurrency: a.args.MaxConcurrency,
RuleConfigSHA256: a.ruleConfigSHA256(),
RuntimeConfigSHA256: a.runtimeConfigSHA256(),
+ PerFileMaxTokens: a.args.Template.MaxTokens,
})
}
@@ -1340,60 +1311,6 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, *subtas
return true, nil, nil
}
-// filterTools defines the two mutually exclusive tools for the review filter.
-// The model MUST call exactly one: either report incorrect comments, or approve all.
-var filterTools = []llm.ToolDef{
- {
- Type: "function",
- Function: llm.FunctionDef{
- Name: "report_incorrect_comments",
- Description: "Report review comments that this diff proves to be factually wrong: either the code they target is absent from the diff, " +
- "or one diff line literally contradicts their central claim. For every id listed you must be able to name that line. " +
- "Do not use this for comments you merely find unconvincing, unverifiable, or low-value, nor for comments about memory safety, " +
- "concurrency, linkage consistency, unused parameters, or behavioral changes.",
- // Field order matters and is load-bearing. Go serializes these
- // properties alphabetically, so "analysis" is emitted before
- // "comment_ids" and the model reasons before it commits. With the
- // order reversed it picks ids first and cannot retract them: replaying
- // recorded sessions showed it writing "this is a protected subject, I
- // should not remove it" in the later field while the id stayed in the
- // earlier one. Do not rename these fields into a different relative
- // order.
- Parameters: map[string]any{
- "type": "object",
- "properties": map[string]any{
- "analysis": map[string]any{
- "type": "array",
- "description": "Work through every candidate comment BEFORE deciding. One entry per candidate: its id, " +
- "whether its subject hits the protected-subject veto (Step 1) or the value veto (Step 2), " +
- "the exact diff line that refutes it if any, and your final call. " +
- "Only ids you conclude here as removable may appear in comment_ids.",
- "items": map[string]any{"type": "string"},
- },
- "comment_ids": map[string]any{
- "type": "array",
- "description": "IDs concluded removable in analysis, e.g. [\"c-0\", \"c-2\"]. Must not be empty.",
- "items": map[string]any{"type": "string"},
- },
- },
- "required": []any{"analysis", "comment_ids"},
- },
- },
- },
- {
- Type: "function",
- Function: llm.FunctionDef{
- Name: "approve_all_comments",
- Description: "Keep every review comment. Call this whenever no comment clears the removal bar — including when comments look doubtful, " +
- "cannot be verified from the diff alone, or seem minor. This is the expected outcome for most files.",
- Parameters: map[string]any{
- "type": "object",
- "properties": map[string]any{},
- },
- },
- },
-}
-
// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
// provably incorrect based solely on the diff. Errors are logged and silently ignored.
func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) {
@@ -1438,11 +1355,9 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
resp, err := a.args.LLMClient.CompletionsWithCtx(reqCtx, llm.ChatRequest{
- Model: a.args.Model,
- Messages: messages,
- Tools: filterTools,
- ToolChoice: "required",
- MaxTokens: a.args.Template.CompletionTokenLimit(),
+ Model: a.args.Model,
+ Messages: messages,
+ MaxTokens: a.args.Template.CompletionTokenLimit(),
})
duration := time.Since(startTime)
if err != nil {
@@ -1463,24 +1378,13 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
rec.SetResponse(resp, duration)
a.runner.RecordUsage(resp.Usage)
- indices := parseFilterToolCalls(resp.ToolCalls(), len(comments))
- if indices == nil {
- indices = parseFilterResponse(resp.Content(), len(comments))
- }
+ indices := parseFilterResponse(resp.Content(), len(comments))
telemetry.SetAttr(span, "comments.filtered", len(indices))
if len(indices) == 0 {
- telemetry.Event(ctx, "review_filter.completed",
- attribute.String("file.path", newPath),
- attribute.Int("total_comments", len(comments)),
- attribute.Int("removed", 0))
return
}
a.args.CommentCollector.RemoveByPathAndIndices(newPath, indices)
- telemetry.Event(ctx, "review_filter.completed",
- attribute.String("file.path", newPath),
- attribute.Int("total_comments", len(comments)),
- attribute.Int("removed", len(indices)))
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter removed %d comment(s) for %s\n", len(indices), newPath)
}
@@ -1503,39 +1407,6 @@ func buildFilterCommentsJSON(comments []model.LlmComment) string {
return string(data)
}
-// parseFilterToolCalls extracts comment indices from the filter tool call response.
-// Returns nil if no matching tool call is found, allowing fallback to text-based parsing.
-// Returns an empty map (non-nil) for approve_all_comments or an empty comment_ids list.
-func parseFilterToolCalls(calls []llm.ToolCall, total int) map[int]struct{} {
- var indices map[int]struct{}
- for _, call := range calls {
- switch call.Function.Name {
- case "approve_all_comments":
- if indices == nil {
- indices = make(map[int]struct{})
- }
- case "report_incorrect_comments":
- var args struct {
- CommentIDs []string `json:"comment_ids"`
- }
- if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
- fmt.Fprintf(stdout.Writer(), "[ocr] Review filter: failed to parse tool call arguments: %v\n", err)
- continue
- }
- if indices == nil {
- indices = make(map[int]struct{})
- }
- for _, id := range args.CommentIDs {
- var idx int
- if _, err := fmt.Sscanf(id, "c-%d", &idx); err == nil && idx >= 0 && idx < total {
- indices[idx] = struct{}{}
- }
- }
- }
- }
- return indices
-}
-
// parseFilterResponse extracts comment indices from the LLM filter response.
// Returns a set of 0-based indices. Invalid IDs or out-of-range indices are ignored.
func parseFilterResponse(raw string, total int) map[int]struct{} {
@@ -1852,14 +1723,6 @@ func orderedToolParameters(raw json.RawMessage) ([]orderedToolParameter, bool) {
return params, true
}
-// allDiffs exposes the reviewed diff set for cross-file comment re-filing.
-// It is read-only and safe to call from the per-file subtask goroutines: every
-// mutation of a.diffs (filterDiffs, filterLargeDiffs) completes before dispatch
-// begins, so the slice is stable for the rest of the run.
-func (a *Agent) allDiffs() []model.Diff {
- return a.diffs
-}
-
// findDiff returns the Diff for the given file path, or nil if not found.
func (a *Agent) findDiff(path string) *model.Diff {
for i := range a.diffs {
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 33968295..a6dbd6a9 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -223,96 +223,6 @@ func TestParseFilterResponse(t *testing.T) {
}
}
-func TestParseFilterToolCalls(t *testing.T) {
- tests := []struct {
- name string
- calls []llm.ToolCall
- total int
- wantSet map[int]struct{}
- }{
- {
- name: "no tool calls",
- calls: nil,
- total: 5,
- wantSet: nil,
- },
- {
- name: "report_incorrect_comments with IDs",
- calls: []llm.ToolCall{{
- Function: llm.FunctionCall{
- Name: "report_incorrect_comments",
- Arguments: `{"comment_ids": ["c-0", "c-2"]}`,
- },
- }},
- total: 5,
- wantSet: map[int]struct{}{0: {}, 2: {}},
- },
- {
- name: "approve_all_comments returns empty map",
- calls: []llm.ToolCall{{
- Function: llm.FunctionCall{
- Name: "approve_all_comments",
- Arguments: `{}`,
- },
- }},
- total: 5,
- wantSet: map[int]struct{}{},
- },
- {
- name: "ignores non-matching tool names",
- calls: []llm.ToolCall{{
- Function: llm.FunctionCall{
- Name: "other_tool",
- Arguments: `{"comment_ids": ["c-0"]}`,
- },
- }},
- total: 5,
- wantSet: nil,
- },
- {
- name: "out-of-range indices ignored",
- calls: []llm.ToolCall{{
- Function: llm.FunctionCall{
- Name: "report_incorrect_comments",
- Arguments: `{"comment_ids": ["c-0", "c-10"]}`,
- },
- }},
- total: 5,
- wantSet: map[int]struct{}{0: {}},
- },
- {
- name: "invalid JSON arguments falls through",
- calls: []llm.ToolCall{{
- Function: llm.FunctionCall{
- Name: "report_incorrect_comments",
- Arguments: `not json`,
- },
- }},
- total: 5,
- wantSet: nil,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := parseFilterToolCalls(tt.calls, tt.total)
- if tt.wantSet == nil {
- if got != nil {
- t.Errorf("expected nil, got %v", got)
- }
- return
- }
- if len(got) != len(tt.wantSet) {
- t.Fatalf("len = %d, want %d; got %v", len(got), len(tt.wantSet), got)
- }
- for idx := range tt.wantSet {
- if _, ok := got[idx]; !ok {
- t.Errorf("missing index %d in result", idx)
- }
- }
- })
- }
-}
-
func TestExtFromPath(t *testing.T) {
a := New(Args{})
diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go
index e408c71e..5bff00aa 100644
--- a/internal/agent/coverage_test.go
+++ b/internal/agent/coverage_test.go
@@ -269,19 +269,11 @@ func TestExecuteReviewFilter_RemovesComments(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+ filterResp := `["c-1"]`
client := &fakeAgentClient{
responses: []*llm.ChatResponse{{
Choices: []llm.Choice{{
- Message: llm.ResponseMessage{
- ToolCalls: []llm.ToolCall{{
- ID: "call_1",
- Type: "function",
- Function: llm.FunctionCall{
- Name: "report_incorrect_comments",
- Arguments: `{"comment_ids":["c-1"]}`,
- },
- }},
- },
+ Message: llm.ResponseMessage{Content: &filterResp},
}},
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
}},
@@ -426,19 +418,11 @@ func TestExecuteReviewFilter_SkipFilter(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+ filterResp := `["c-1"]`
client := &fakeAgentClient{
responses: []*llm.ChatResponse{{
Choices: []llm.Choice{{
- Message: llm.ResponseMessage{
- ToolCalls: []llm.ToolCall{{
- ID: "call_1",
- Type: "function",
- Function: llm.FunctionCall{
- Name: "report_incorrect_comments",
- Arguments: `{"comment_ids":["c-1"]}`,
- },
- }},
- },
+ Message: llm.ResponseMessage{Content: &filterResp},
}},
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
}},
@@ -791,21 +775,11 @@ func TestExecuteReviewFilter_WithTimeout(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+ filterResp := `[]`
client := &fakeAgentClient{
responses: []*llm.ChatResponse{{
- Choices: []llm.Choice{{
- Message: llm.ResponseMessage{
- ToolCalls: []llm.ToolCall{{
- ID: "call_1",
- Type: "function",
- Function: llm.FunctionCall{
- Name: "approve_all_comments",
- Arguments: `{}`,
- },
- }},
- },
- }},
- Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 2},
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &filterResp}}},
+ Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 2},
}},
}
diff --git a/internal/agent/identity.go b/internal/agent/identity.go
index 44d3f19d..8089b555 100644
--- a/internal/agent/identity.go
+++ b/internal/agent/identity.go
@@ -111,6 +111,7 @@ func (a *Agent) runIdentity() session.RunIdentity {
Mode: a.manifestMode(),
SourceArtifactSHA256: a.sourceArtifactSHA256(),
RuleConfigSHA256: a.ruleConfigSHA256(),
+ PerFileMaxTokens: a.args.Template.MaxTokens,
}
if raw := a.repoRemoteIdentity; raw != "" {
sum := sha256.Sum256([]byte(raw))
diff --git a/internal/agent/manifest_integration_test.go b/internal/agent/manifest_integration_test.go
index a1ca52d9..5a823e35 100644
--- a/internal/agent/manifest_integration_test.go
+++ b/internal/agent/manifest_integration_test.go
@@ -9,7 +9,6 @@ import (
"fmt"
"strings"
"sync"
- "sync/atomic"
"testing"
"time"
@@ -43,26 +42,6 @@ func (manifestFlowClient) CompletionsWithCtx(_ context.Context, req llm.ChatRequ
}
}
-type cancellationFlowClient struct {
- blocked chan struct{}
- once sync.Once
-}
-
-func (c *cancellationFlowClient) CompletionsWithCtx(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
- var prompt string
- for _, message := range req.Messages {
- if text, ok := message.Content.(string); ok {
- prompt += text
- }
- }
- if strings.Contains(prompt, "blocked.go") {
- c.once.Do(func() { close(c.blocked) })
- <-ctx.Done()
- return nil, ctx.Err()
- }
- return agentTaskDoneResponse(), nil
-}
-
func newManifestFlowAgent(t *testing.T, diffs []model.Diff, resume *session.ResumeState) *Agent {
t.Helper()
return newManifestFlowAgentWithClient(t, diffs, resume, manifestFlowClient{})
@@ -189,101 +168,6 @@ func TestManifestFlowCompleteAndPartial(t *testing.T) {
})
}
-func TestManifestFlowCancellationPersistsResumableSession(t *testing.T) {
- done := model.Diff{OldPath: "done.go", NewPath: "done.go", Diff: "+done", Insertions: 1}
- blocked := model.Diff{OldPath: "blocked.go", NewPath: "blocked.go", Diff: "+blocked", Insertions: 1}
- pending := model.Diff{OldPath: "pending.go", NewPath: "pending.go", Diff: "+pending", Insertions: 1}
- client := &cancellationFlowClient{blocked: make(chan struct{})}
- a := newManifestFlowAgentWithClient(t, []model.Diff{done, blocked, pending}, nil, client)
- a.args.MaxConcurrency = 1
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- dispatchErr := make(chan error, 1)
- go func() {
- _, err := a.dispatchSubtasks(ctx)
- dispatchErr <- err
- }()
-
- select {
- case <-client.blocked:
- cancel()
- case <-time.After(5 * time.Second):
- t.Fatal("blocked review did not start")
- }
- if err := <-dispatchErr; !errors.Is(err, context.Canceled) {
- t.Fatalf("dispatch error = %v, want context.Canceled", err)
- }
-
- manifest := finishManifestFlow(t, a)
- if manifest.RunFailure == nil || manifest.RunFailure.Classification != session.RunFailureCancelled {
- t.Fatalf("run failure = %+v, want cancelled", manifest.RunFailure)
- }
- if len(manifest.Coverage.Completed) != 1 || len(manifest.Coverage.Failed) != 2 {
- t.Fatalf("coverage = %+v, want one completed and two failed", manifest.Coverage)
- }
- for _, item := range manifest.Coverage.Failed {
- if item.Classification != session.FailureCancelled {
- t.Fatalf("failed item = %+v, want cancelled", item)
- }
- }
-
- state, err := session.LoadReviewResumeState(a.args.RepoDir, a.session.SessionID)
- if err != nil {
- t.Fatalf("load cancelled session: %v", err)
- }
- identity := session.RunIdentity{
- Mode: manifest.Input.Mode,
- SourceArtifactSHA256: manifest.Input.SourceArtifactSHA256,
- RuleConfigSHA256: manifest.Execution.RuleConfigSHA256,
- RepositorySHA256: manifest.Repository.IdentitySHA256,
- }
- if err := state.ValidateResume(session.ResumeRequest{
- Identity: identity,
- Provider: manifest.Execution.Provider,
- Model: manifest.Execution.Model,
- }); err != nil {
- t.Fatalf("cancelled session should remain resumable: %v", err)
- }
- if _, ok := state.ReusableItem(reviewItemFingerprint(a.reviewMode(), done)); !ok {
- t.Fatal("completed item is not reusable after cancellation")
- }
- if _, ok := state.ReusableItem(reviewItemFingerprint(a.reviewMode(), blocked)); ok {
- t.Fatal("cancelled item must be reviewed again")
- }
-}
-
-func TestManifestFlowCancellationBeforeDispatchStartsNoSubtask(t *testing.T) {
- pending := model.Diff{OldPath: "blocked.go", NewPath: "blocked.go", Diff: "+blocked", Insertions: 1}
- client := &cancellationFlowClient{blocked: make(chan struct{})}
- a := newManifestFlowAgentWithClient(t, []model.Diff{pending}, nil, client)
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- if _, err := a.dispatchSubtasks(ctx); !errors.Is(err, context.Canceled) {
- t.Fatalf("dispatch error = %v, want context.Canceled", err)
- }
- if got := atomic.LoadInt64(&a.subtaskFailed); got != 0 {
- t.Fatalf("subtask failures = %d, want 0 because no subtask should start", got)
- }
- select {
- case <-client.blocked:
- t.Fatal("LLM client was called after cancellation")
- default:
- }
-
- manifest := finishManifestFlow(t, a)
- if manifest.RunFailure == nil || manifest.RunFailure.Classification != session.RunFailureCancelled {
- t.Fatalf("run failure = %+v, want cancelled", manifest.RunFailure)
- }
- if len(manifest.Coverage.Completed) != 0 || len(manifest.Coverage.Failed) != 1 {
- t.Fatalf("coverage = %+v, want one cancelled item and no completed items", manifest.Coverage)
- }
- if got := manifest.Coverage.Failed[0].Classification; got != session.FailureCancelled {
- t.Fatalf("failure classification = %q, want %q", got, session.FailureCancelled)
- }
-}
-
func TestManifestFlowRunInputFailureIsPersisted(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
diff --git a/internal/config/allowlist/allowed_ext_test.go b/internal/config/allowlist/allowed_ext_test.go
index 4939318e..48cbd791 100644
--- a/internal/config/allowlist/allowed_ext_test.go
+++ b/internal/config/allowlist/allowed_ext_test.go
@@ -145,16 +145,6 @@ func TestIsExcludedPath(t *testing.T) {
{"julia test nested", "MyPkg/test/unit/foo.jl", true},
{"julia non-test", "src/model.jl", false},
- // Swift test files
- {"swift Tests suffix", "MyAppTests/UserTests.swift", true},
- {"swift Tests suffix nested", "Tests/AppTests/UserTests.swift", true},
- {"swift UITests suffix", "MyAppUITests/LaunchTests.swift", true},
- {"swift Test suffix", "MyAppTests/UserTest.swift", true},
- {"swift Test dir helper", "Tests/AppTests/Mocks/MockService.swift", true},
- {"swift tests dir lowercase", "tests/AppTests/Helpers/Helper.swift", true},
- {"swift non-test", "Sources/App/User.swift", false},
- {"swift helper with test in name", "Sources/App/TestSupport.swift", false},
-
// Haskell test files
{"haskell test directory", "test/Parser.hs", true},
{"haskell nested test directory", "packages/core/test/unit/Parser.hs", true},
diff --git a/internal/config/allowlist/default_exclude_patterns.json b/internal/config/allowlist/default_exclude_patterns.json
index 1eb43a1e..637d6616 100644
--- a/internal/config/allowlist/default_exclude_patterns.json
+++ b/internal/config/allowlist/default_exclude_patterns.json
@@ -29,8 +29,5 @@
"**/*.gen.go",
"**/*.pb.go",
"**/*.pb.cc",
- "**/*.pb.h",
- "**/*Test.swift",
- "**/*Tests.swift",
- "**/Tests/**/*.swift"
+ "**/*.pb.h"
]
diff --git a/internal/config/rules/rule_docs/swift.md b/internal/config/rules/rule_docs/swift.md
deleted file mode 100644
index 528e99fa..00000000
--- a/internal/config/rules/rule_docs/swift.md
+++ /dev/null
@@ -1,105 +0,0 @@
-#### Swift Review Principles
-> Favor precision over recall: report only defects likely real in changed code and reachable execution paths. Prioritize crashes, data corruption, security issues, privacy issues, and concurrency bugs. Do not report style preferences.
-
-Before reporting non-local behavior, use `file_read` and `code_search` to verify ownership, callers, synchronization, lifecycle, and input sources. Do not infer threading, retain cycles, or error contracts only from names or types. Do not duplicate compiler, SwiftLint, or Xcode analyzer findings unless the diff creates concrete correctness impact.
-
-#### Optionals and Runtime Failures
-- Force unwrap, force cast, or `try!` on runtime-derived values (user input, network responses, persistence, decoding, external state) where failure is reachable and not handled.
-- Implicitly unwrapped optionals outside controlled framework lifecycle patterns where access can occur before initialization or after invalidation.
-- Optional handling that converts required failure into silent incorrect behavior, missing data, or invalid state.
-
-#### Memory Ownership and ARC
-- Escaping closures stored by an object that strongly capture that same object, creating a retain cycle.
-- `[unowned]` captures in escaping closures where object lifetime is not guaranteed until execution.
-- Delegate, observer, callback, timer, or task relationships that create ownership cycles or continue work after owner destruction.
-- Combine subscriptions capturing `self` strongly inside an owner of the cancellable when it prevents expected deallocation.
-- Async streams, notifications, timers, or subscriptions started without lifecycle cleanup when they continue after dismissal/deallocation.
-
-#### Error Handling
-- Throwing operations or `Result` failures ignored, replaced with success values, or hidden when failure changes behavior or data correctness.
-- `try?` removing required failure information where callers need failure distinction.
-- Empty error handling blocks suppressing failures affecting integrity, security, or user-visible behavior.
-- Error wrapping removing typed error information required by callers.
-- `fatalError`/`preconditionFailure` used for recoverable runtime failures instead of typed propagation.
-
-#### Swift Concurrency and Isolation
-- Mutable state accessed across actor boundaries without isolation or synchronization where concurrent access is possible.
-- Non-`Sendable` values crossing isolation boundaries where races or unsafe assumptions are possible.
-- `@unchecked Sendable` or `nonisolated(unsafe)` introduced without a proven thread-safety invariant.
-- Actor-isolated state accessed from callbacks, delegates, or closures without preserving isolation.
-- Fire-and-forget tasks that outlive owners, cannot be cancelled, or continue side effects after lifecycle ends.
-- Detached tasks used where inherited actor context, priority, cancellation, or isolation is required.
-- Async work ignoring cancellation and continuing expensive computation or side effects.
-- Continuation wrappers that can resume multiple times, never resume, or resume after ownership/lifecycle invalidation.
-- Locks or synchronous waits used across `await` boundaries.
-- Independent async operations introduced sequentially causing measurable user-visible latency regression.
-
-#### SwiftUI State and Lifecycle
-- View-owned reference state recreated across renders because ownership/lifetime is incorrect.
-- Dynamic collections using unstable identity causing incorrect row reuse or state association.
-- Side effects executed from `body` or computed properties causing repeated execution.
-- Lifecycle async work continuing after disappearance when cancellation ownership is required.
-- `.task(id:)` missing where replaced inputs can allow stale results to overwrite newer state.
-- UI state mutated outside required main actor isolation when concurrent updates are possible.
-- Lifecycle effects duplicated or misattributed across remount, presentation, or dismissal paths.
-- User-visible strings added or changed without localization coverage.
-
-#### Persistence and Data Integrity (SwiftData / Core Data)
-- Persistence writes leaving stored state partially updated or inconsistent after failure.
-- Schema or relationship changes without compatible migration handling for existing data.
-- Relationship configuration changes causing orphaned objects, invalid references, or incorrect delete behavior.
-- `@Query`/fetch predicates or sort descriptors matching incorrect data or causing avoidable expensive fetches.
-- Cached or persisted values treated as authoritative when they can become stale and affect correctness.
-
-#### Health and Privacy Data
-- HealthKit access performed without required authorization handling or safe fallback behavior.
-- Health or sensitive data written to logs, analytics, insecure storage, or plaintext persistence.
-- Health claims introduced without required supporting source or compliance basis.
-- Health queries, observer queries, or background delivery registrations missing lifecycle handling, causing missed updates or unnecessary resource use.
-
-#### Purchases and Entitlements
-- Purchase, restore, or entitlement state failing to handle pending, offline, or verification outcomes.
-- Transaction listeners missing, incorrectly scoped, or failing to consume verified transactions.
-- Paywall or entitlement UI using stale state instead of canonical entitlement state.
-- Trial, restore, or purchase error paths granting or revoking entitlement incorrectly.
-
-#### Combine and Reactive Streams
-- Combine subscriptions causing ownership cycles or continuing after intended lifecycle.
-- UI updates delivered without required scheduler guarantees (`receive(on:)`/equivalent), causing incorrect thread execution.
-- Expensive upstream work executed on inappropriate schedulers where it blocks UI or causes latency.
-- Streams without cancellation/backpressure handling where unbounded work or memory growth is possible.
-
-#### Networking
-- Authentication tokens, credentials, or sensitive data exposed through logs, storage, or requests.
-- Signed/authenticated URLs with bypassed expiry, validation, or authorization checks.
-- Retry logic causing request storms or missing backoff for transient failures.
-- Cache handling serving stale or unauthorized responses.
-- Disabled transport protections or weakened certificate validation where an existing security boundary depends on it.
-- Client-controlled identity, authorization, or payment values trusted without server validation.
-
-#### Web Views, Deep Links, and External Input
-- WKWebView JavaScript bridges accepting unvalidated messages or exposing privileged actions.
-- Navigation handlers allowing untrusted URLs or schemes without validation.
-- Deep-link inputs changing authenticated state or sensitive actions without validation.
-
-#### Performance and Resource Usage
-- Expensive synchronous work on the main actor/thread blocking interaction.
-- Repeated expensive work on frequently executed paths causing measurable regressions.
-- Unbounded memory growth from collections, caches, tasks, streams, or retained objects.
-- Inefficient algorithms on demonstrably large collections causing user-visible slowdown.
-
-#### Security
-- Secrets, credentials, tokens, private keys, or sensitive user data added to source, logs, fixtures, or insecure storage.
-- User-controlled input passed into executable contexts, unsafe URLs, queries, or commands without validation.
-
-#### Unsafe Interoperability
-- Unsafe pointer, buffer, or memory APIs used without guaranteed lifetime or bounds.
-- Objective-C/C bridging violating ownership, nullability, or lifetime assumptions.
-
-#### Testing Correctness
-- Tests relying on arbitrary sleeps or timing delays instead of async expectations or direct awaiting.
-- Tests not exercising changed behavior paths where regressions are likely.
-- Tests sharing mutable global state causing isolation failures.
-- Async tests leaving tasks running after completion.
-- Assertions that cannot fail for the regression they intend to detect.
-- Tests depending on uncontrolled environment state (network, time, locale, global persistence) where isolation is required.
diff --git a/internal/config/rules/system_rules.json b/internal/config/rules/system_rules.json
index a469d147..8646103a 100644
--- a/internal/config/rules/system_rules.json
+++ b/internal/config/rules/system_rules.json
@@ -34,7 +34,6 @@
"**/*.bicep": "bicep.md",
"**/*.nix": "nix.md",
"**/*.{hs,lhs}": "haskell.md",
- "**/*.{nim,nims,nimble}": "nim.md",
- "**/*.swift": "swift.md"
+ "**/*.{nim,nims,nimble}": "nim.md"
}
}
diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go
index 6afd8a3a..34e95608 100644
--- a/internal/config/rules/system_rules_test.go
+++ b/internal/config/rules/system_rules_test.go
@@ -115,9 +115,6 @@ func TestResolve_DefaultRules(t *testing.T) {
{"src/parser.nim", "Memory and Lifetime Safety"},
{"scripts/build.nims", "Memory and Lifetime Safety"},
{"project.nimble", "Memory and Lifetime Safety"},
- {"Sources/App/ContentView.swift", "Swift Review Principles"},
- {"MyApp/Models/UserStore.swift", "Swift Review Principles"},
- {"ChattyFit/ChattyFit/Views/WorkoutSessionView.swift", "SwiftUI State and Lifecycle"},
}
for _, tt := range tests {
@@ -141,8 +138,8 @@ func TestResolve_FallbackToDefault(t *testing.T) {
"readme.md",
"docs/architecture.txt",
"Makefile",
+ "ios/ViewController.swift",
"ios/ViewController.m",
- "ios/ViewController.mm",
}
for _, path := range paths {
diff --git a/internal/config/template/prompts/review_filter_task_system.md b/internal/config/template/prompts/review_filter_task_system.md
index 37cc2213..bff2f0dd 100644
--- a/internal/config/template/prompts/review_filter_task_system.md
+++ b/internal/config/template/prompts/review_filter_task_system.md
@@ -1,12 +1,7 @@
You are a fact-checker for code review comments.
-These review comments come from an Agent that could invoke tools to read the full codebase. You can see only a single diff. Anything you cannot see, the Agent may well have seen.
+These review comments come from an Agent that can invoke tools to obtain the full code context. You can currently only see the code diff.
-Your task is narrow: remove only the comments that this diff **proves** to be factually wrong. You are not judging whether a comment is useful, well-prioritized, or worth a reviewer's time.
+Therefore, your task is NOT to verify whether all review comments are correct, but to **filter out only those review comments that can be confirmed as incorrect based solely on the current diff**.
-The two mistakes available to you are not equally bad:
-
-- Keeping an incorrect comment costs a reviewer a few seconds of attention.
-- Removing a correct comment silently destroys a real finding. It never reaches anyone, and nobody learns that it was dropped.
-
-So when your evidence falls short of proof, approve. "Suspicious", "I cannot verify this", "low value", "the flagged code looks fine to me", and "I would not have raised this" all mean approve.
+For review comments whose correctness cannot be determined from the diff alone, even if you find them suspicious, you should let them pass — because the Agent may have access to context that you cannot see.
diff --git a/internal/config/template/prompts/review_filter_task_user.md b/internal/config/template/prompts/review_filter_task_user.md
index d4858a5d..d2b64725 100644
--- a/internal/config/template/prompts/review_filter_task_user.md
+++ b/internal/config/template/prompts/review_filter_task_user.md
@@ -1,69 +1,34 @@
### Task
-Below is one file's diff and a set of review comments about it. Identify only the comments that this diff **proves** to be wrong.
+Given a code diff and a set of review comments, identify those that are **provably incorrect based solely on the diff**.
-Your default answer is to approve everything. On most files that is the correct answer.
+### Evaluation Principles
-### The only two grounds for removal
+**Core principle: You need to falsify, not verify.**
-**Ground A — the comment targets code that is not in this diff.**
+- ✅ Should flag: The diff contains **direct counter-evidence** that proves the key claim of the review comment is wrong
+- ❌ Should NOT flag: The review comment references context not visible in the diff (may have been obtained by the Agent via tools)
+- ❌ Should NOT flag: You merely "cannot verify" but also cannot disprove the review comment
-The symbol, statement, or construct the comment describes appears nowhere in the diff below. Typical shapes:
+### Evaluation Method
-- it discusses the body of a function, on a file that only declares or references it
-- it discusses host-language logic on a file that holds none — a query, build, markup, or configuration file
-- it claims code was removed, or an error is handled, and this diff contains no such change
+For each review comment, perform the following two steps:
-**Ground B — a specific line of the diff literally contradicts the comment's central claim.**
+#### Step 1: Fact Check (Veto Rule)
-The comment asserts a concrete fact and the diff shows the opposite in plain text. The contradiction must be readable straight off the diff, not derived through a chain of reasoning. Typical shapes:
+- Only verify claims that are verifiable within the diff
+- Only determine a comment as incorrect when the diff provides counter-evidence. **If a claim involves information outside the diff (such as logic in other files, business semantics, runtime behavior), and the diff contains no evidence contradicting it, do not make a determination.**
-- it says an identifier is unused, and the diff shows it in use
-- it says a check, assertion, or branch is missing, and the diff contains it
-- it says a value is hardcoded, and the diff shows it read from a variable
-- it says something is declared twice and shadows an outer name, and the diff holds exactly one declaration
-- it states a condition or type relationship that the diff's own text refutes
+⚠️ Fact check fails → Immediately determine as incorrect, skip Step 2.
-If you cannot point to the specific diff line that establishes Ground A or Ground B, approve the comment.
+#### Step 2: Issue Classification
-### Protected subjects — never remove
+After confirming that the facts visible in the diff are accurate, determine whether the description contains a **significant deviation that can be disproved from the diff**:
-These are vetoes, applied before you judge correctness at all. Whatever you conclude about the comment, approve it if its subject is:
+- Does it misidentify clearly normal code in the diff as a defect?
+- Does it attribute behavior visible in the diff in a way that contradicts the code?
-- **Memory safety** — allocation size, buffer length, index bounds, off-by-one, use-after-free, null dereference
-- **Concurrency** — locks and lock modes, atomics, data races, synchronization arguments that are not honored
-- **Linkage and declaration consistency** — `static` versus non-`static`, a declaration that disagrees with its definition, missing `extern`
-- **Behavioral or compatibility change** — a message, field, status, or default that the old code produced and the new code no longer does; an altered error path; a counter whose update moved to a different point in the lifecycle
-- **A parameter the function accepts and never uses**
-
-These are the categories where a wrongly removed comment is most expensive, and where your own confidence is least trustworthy — including confidence that the language, compiler, or runtime does not behave the way the comment claims. On a protected subject you do not get to be confident. Approve.
-
-### Not grounds for removal
-
-- The comment is about style, formatting, naming, blank lines, the wording of a code comment, or readability — **provided what it states is true**. Low value is not incorrectness, and filtering by value is not your job.
-- The comment reasons about runtime behavior, business semantics, or code in files you cannot see. The Agent had access you do not.
-- You disagree with its recommendation, or you consider the flagged code acceptable as written.
-- You cannot confirm it. Unverifiable is not incorrect.
-- It identifies a real problem but quotes a slightly wrong line or snippet. Judge the claim, not the citation.
-- It is imprecise in passing while its central claim holds.
-
-### Method
-
-Run these steps in order for every comment. Stop at the first step that applies — do not revisit a decision a later step would have made differently.
-
-**Step 1 — protected-subject veto.** Is the comment's subject one of the protected categories above (memory safety, concurrency, linkage and declaration consistency, behavioral or compatibility change, an unused parameter)? → **approve and stop.** Do not assess whether it is correct. This veto outranks Ground A and Ground B: a comment on a protected subject stays even when you are confident it is wrong.
-
-**Step 2 — value veto.** Is the comment about style, formatting, naming, blank lines, the wording of a code comment, or readability, and is what it states true of this diff? → **approve and stop.** Its low value is not your concern.
-
-**Step 3 — Ground A.** Is the code it describes absent from the diff? → **remove it.**
-
-**Step 4 — Ground B.** Is there one diff line that literally contradicts its central claim, requiring no chain of reasoning to see? → **remove it.** Steps 3 and 4 are not optional: once a comment reaches them and qualifies, report it.
-
-Before concluding a contradiction in Step 4, search the whole diff for what the comment describes — not only the snippet it quoted. A comment that cites the wrong line while describing something the diff does contain is correct, and stays.
-
-**Step 5 —** approve.
-
-Reaching Step 4 and needing more than a single inferential step to reach the contradiction means there is none. Approve.
+⚠️ Only determine as incorrect when the diff can directly prove the description is wrong.
### Code Diff
@@ -77,7 +42,14 @@ Reaching Step 4 and needing more than a single inferential step to reach the con
### Output
-You must call exactly one tool:
+Return all incorrect review comment IDs directly, without any explanation. Use JSON array format:
-- `report_incorrect_comments` — only for comments meeting Ground A or Ground B, and only if you could name the diff line that disproves each one.
-- `approve_all_comments` — in every other case, including when comments look doubtful, unverifiable, or minor.
+```json
+["id-xxx", "id-yyy"]
+```
+
+If there are no review comments that can be confirmed as incorrect, return an empty array:
+
+```json
+[]
+```
diff --git a/internal/diff/relocate_across_files_test.go b/internal/diff/relocate_across_files_test.go
deleted file mode 100644
index 223a52f8..00000000
--- a/internal/diff/relocate_across_files_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright 2026 alibaba/open-code-review Contributors
-
-package diff
-
-import (
- "testing"
-
- "github.com/alibaba/open-code-review/internal/model"
-)
-
-// headerDiff declares the function; sourceDiff implements it. This is the split
-// that produces mis-filed comments in practice: the Agent reviews the header,
-// reads the source through file_read_diff, and files a comment about the body
-// against the header.
-const headerDiff = `diff --git a/src/span.h b/src/span.h
---- a/src/span.h
-+++ b/src/span.h
-@@ -10,3 +10,4 @@
- void span_set_text(span_t * span, const char * text);
-+void span_set_text_fmt(span_t * span, const char * fmt, ...);
-`
-
-const sourceDiff = `diff --git a/src/span.c b/src/span.c
---- a/src/span.c
-+++ b/src/span.c
-@@ -40,4 +40,7 @@
- void span_set_text_fmt(span_t * span, const char * fmt, ...)
- {
-+ char * text = span_vfmt(fmt, args);
-+ if(text == NULL) return;
-+ va_end(args);
- }
-`
-
-func diffsFixture() []model.Diff {
- return []model.Diff{
- {NewPath: "src/span.h", OldPath: "src/span.h", Diff: headerDiff},
- {NewPath: "src/span.c", OldPath: "src/span.c", Diff: sourceDiff},
- }
-}
-
-func TestRelocateAcrossFiles_RefilesToImplementation(t *testing.T) {
- cm := &model.LlmComment{
- Path: "src/span.h",
- Content: "va_end is skipped on the early return",
- ExistingCode: "\tif(text == NULL) return;\n\tva_end(args);",
- }
-
- got, ok := RelocateAcrossFiles(cm, diffsFixture())
- if !ok {
- t.Fatal("expected a unique hit in src/span.c")
- }
- if got != "src/span.c" || cm.Path != "src/span.c" {
- t.Fatalf("path = %q, cm.Path = %q; want src/span.c for both", got, cm.Path)
- }
- // Line numbers must move with the path, or the comment is re-filed onto the
- // right file while still pointing at the wrong line.
- if cm.StartLine <= 0 || cm.EndLine < cm.StartLine {
- t.Fatalf("StartLine/EndLine = %d/%d; want a resolved range", cm.StartLine, cm.EndLine)
- }
-}
-
-func TestRelocateAcrossFiles_DeclinesWhenAmbiguous(t *testing.T) {
- // The same excerpt in two files: re-filing onto either one would just swap
- // one wrong location for another, so the comment must be left alone.
- dup := `diff --git a/src/other.c b/src/other.c
---- a/src/other.c
-+++ b/src/other.c
-@@ -1,2 +1,4 @@
-+ char * text = span_vfmt(fmt, args);
-+ if(text == NULL) return;
-+ va_end(args);
- }
-`
- diffs := append(diffsFixture(), model.Diff{NewPath: "src/other.c", OldPath: "src/other.c", Diff: dup})
- cm := &model.LlmComment{
- Path: "src/span.h",
- ExistingCode: "\tif(text == NULL) return;\n\tva_end(args);",
- }
-
- if _, ok := RelocateAcrossFiles(cm, diffs); ok {
- t.Fatal("expected no verdict when the excerpt matches more than one file")
- }
- if cm.Path != "src/span.h" || cm.StartLine != 0 || cm.EndLine != 0 {
- t.Fatalf("cm mutated on decline: path=%q start=%d end=%d", cm.Path, cm.StartLine, cm.EndLine)
- }
-}
-
-func TestRelocateAcrossFiles_DeclinesWhenAbsent(t *testing.T) {
- cm := &model.LlmComment{
- Path: "src/span.h",
- ExistingCode: "int nothing_here = 0;",
- }
- if _, ok := RelocateAcrossFiles(cm, diffsFixture()); ok {
- t.Fatal("expected no hit for code absent from every diff")
- }
- if cm.Path != "src/span.h" {
- t.Fatalf("cm.Path mutated to %q on decline", cm.Path)
- }
-}
-
-func TestRelocateAcrossFiles_SkipsOwnFileAndEmptyInputs(t *testing.T) {
- // A comment already resolvable in its own file must not be re-filed: the
- // caller only reaches here after same-file resolution failed, but the
- // function still has to exclude cm.Path so a self-match cannot masquerade
- // as a cross-file hit.
- cm := &model.LlmComment{
- Path: "src/span.c",
- ExistingCode: "\tva_end(args);",
- }
- if _, ok := RelocateAcrossFiles(cm, diffsFixture()); ok {
- t.Fatal("expected the comment's own file to be excluded from the search")
- }
-
- for name, arg := range map[string]*model.LlmComment{
- "nil comment": nil,
- "empty ExistingCode": {Path: "src/span.h"},
- } {
- if _, ok := RelocateAcrossFiles(arg, diffsFixture()); ok {
- t.Fatalf("%s: expected no hit", name)
- }
- }
- if _, ok := RelocateAcrossFiles(&model.LlmComment{ExistingCode: "x"}, nil); ok {
- t.Fatal("empty diff set: expected no hit")
- }
-}
diff --git a/internal/diff/resolver.go b/internal/diff/resolver.go
index 8443dfd7..d776b259 100644
--- a/internal/diff/resolver.go
+++ b/internal/diff/resolver.go
@@ -72,72 +72,6 @@ func ResolveComment(cm *model.LlmComment, d *model.Diff) bool {
return resolveFromFileContent(d, cm)
}
-// RelocateAcrossFiles handles the comment whose ExistingCode belongs to a
-// different file than the one it was filed against.
-//
-// The reviewing Agent reads related files through file_read_diff, so it can
-// describe code from a file other than the one under review and still file the
-// comment against the file under review — typically a declaration/implementation
-// split, where the comment lands on the header and its code lives in the source
-// file. ResolveComment then fails, and the LLM re-location that follows is given
-// only the wrong file's diff and a prompt that demands a code block back, so it
-// answers with whatever token in that diff looks closest. That overwrites the
-// one piece of evidence pointing at the real code, and the comment ends up
-// looking located while pointing at an unrelated line.
-//
-// So this runs first, and without a model: ExistingCode is a verbatim excerpt,
-// which makes finding its true home plain string matching over the diffs that
-// are already in memory. On a unique hit the comment is re-filed — Path,
-// StartLine and EndLine all move together — and it returns that path.
-//
-// Zero hits and multiple hits both decline, leaving cm untouched: the same
-// boilerplate can legitimately appear in several files, and guessing between
-// them would trade one wrong location for another. Callers should treat a
-// false return as "still unlocated" rather than as an error.
-//
-// cm.Path is skipped because its own file has already been tried, and probing
-// happens on a copy so a failed candidate cannot leave line numbers behind.
-func RelocateAcrossFiles(cm *model.LlmComment, diffs []model.Diff) (string, bool) {
- if cm == nil || cm.ExistingCode == "" || len(diffs) == 0 {
- return "", false
- }
-
- type hit struct {
- path string
- start, end int
- }
- var hits []hit
-
- for i := range diffs {
- d := &diffs[i]
- if d.NewPath == cm.Path || d.OldPath == cm.Path {
- continue
- }
- probe := *cm
- probe.StartLine, probe.EndLine = 0, 0
- if !ResolveComment(&probe, d) {
- continue
- }
- path := d.NewPath
- if path == "" {
- path = d.OldPath
- }
- hits = append(hits, hit{path: path, start: probe.StartLine, end: probe.EndLine})
- if len(hits) > 1 {
- // Ambiguous already; no verdict can come from looking further.
- return "", false
- }
- }
-
- if len(hits) != 1 {
- return "", false
- }
- cm.Path = hits[0].path
- cm.StartLine = hits[0].start
- cm.EndLine = hits[0].end
- return hits[0].path, true
-}
-
// indexedLine pairs a normalized line with its absolute file line number.
type indexedLine struct {
lineNum int
diff --git a/internal/llm/client.go b/internal/llm/client.go
index e3ec7190..5e2bb801 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -403,7 +403,6 @@ type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []ToolDef `json:"tools,omitempty"`
- ToolChoice string `json:"tool_choice,omitempty"` // "auto", "required", or "none"; empty means provider default
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
SessionID string `json:"-"` // per-file agent loop session ID; used as prompt_cache_key by the Responses API client
@@ -647,11 +646,6 @@ func (c *OpenAIClient) buildOpenAIParams(model string, req ChatRequest) openai.C
if len(tools) > 0 {
params.Tools = tools
}
- if req.ToolChoice != "" && len(tools) > 0 {
- params.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{
- OfAuto: openai.String(req.ToolChoice),
- }
- }
if req.MaxTokens > 0 {
params.MaxCompletionTokens = openai.Int(int64(req.MaxTokens))
}
@@ -952,11 +946,6 @@ func (c *AnthropicClient) buildAnthropicParams(model string, req ChatRequest) (a
if len(tools) > 0 {
tools[len(tools)-1].OfTool.CacheControl = anthropic.NewCacheControlEphemeralParam()
params.Tools = tools
- if req.ToolChoice == "required" {
- params.ToolChoice = anthropic.ToolChoiceUnionParam{
- OfAny: &anthropic.ToolChoiceAnyParam{},
- }
- }
}
// Dynamic breakpoint on the latest message so multi-turn history is
// cached incrementally: read the full previous prefix, write only the delta.
diff --git a/internal/llm/providers.go b/internal/llm/providers.go
index 71c3afb1..5ddcd015 100644
--- a/internal/llm/providers.go
+++ b/internal/llm/providers.go
@@ -205,20 +205,6 @@ var registry = []Provider{
"kimi-k2.5",
},
},
- {
- Name: "kimi-global",
- DisplayName: "Kimi Moonshot API (Global)",
- Protocol: ProtocolOpenAIChatCompletions,
- BaseURL: "https://api.moonshot.ai/v1",
- EnvVar: "MOONSHOT_GLOBAL_API_KEY",
- Models: []string{
- "kimi-k3",
- "kimi-k2.7-code",
- "kimi-k2.7-code-highspeed",
- "kimi-k2.6",
- "kimi-k2.5",
- },
- },
{
Name: "z-ai",
DisplayName: "Z.AI API",
@@ -240,7 +226,6 @@ var registry = []Provider{
BaseURL: "https://open.bigmodel.cn/api/coding/paas/v4",
EnvVar: "Z_AI_CODING_API_KEY",
Models: []string{
- "glm-5.3",
"glm-5.2",
"glm-5.1",
"glm-5-turbo",
@@ -344,18 +329,6 @@ var registry = []Provider{
"deepseek/deepseek-v4-flash-0731",
},
},
- {
- Name: "xai",
- DisplayName: "xAI Grok API",
- Protocol: ProtocolOpenAIChatCompletions,
- BaseURL: "https://api.x.ai/v1",
- EnvVar: "XAI_API_KEY",
- Models: []string{
- "grok-4.6",
- "grok-4.5",
- "grok-4.3",
- },
- },
{
Name: "litellm",
DisplayName: "LiteLLM AI Gateway",
diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go
index a66c9dbd..a326d76d 100644
--- a/internal/llm/providers_test.go
+++ b/internal/llm/providers_test.go
@@ -75,7 +75,7 @@ func TestListProviders_Order(t *testing.T) {
if len(providers) < 3 {
t.Fatalf("expected at least 3 providers, got %d", len(providers))
}
- expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "edenai", "hy-tokenplan", "iflytek", "kimi", "kimi-global", "litellm", "mimo", "minimax", "minimax-cn", "mistral", "novita", "ollama-cloud", "openai", "siliconflow", "siliconflow-cn", "tencent-tokenhub", "volcengine", "xai", "z-ai", "z-ai-coding"}
+ expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "edenai", "hy-tokenplan", "iflytek", "kimi", "litellm", "mimo", "minimax", "minimax-cn", "mistral", "novita", "ollama-cloud", "openai", "siliconflow", "siliconflow-cn", "tencent-tokenhub", "volcengine", "z-ai", "z-ai-coding"}
if len(providers) != len(expected) {
t.Fatalf("expected %d providers, got %d", len(expected), len(providers))
}
@@ -314,41 +314,6 @@ func TestLookupProvider_MistralDetails(t *testing.T) {
}
}
-func TestLookupProvider_XAIDetails(t *testing.T) {
- p, ok := LookupProvider("xai")
- if !ok {
- t.Fatal("xai not found")
- }
- if p.DisplayName != "xAI Grok API" {
- t.Errorf("DisplayName = %q, want %q", p.DisplayName, "xAI Grok API")
- }
- if p.Protocol != ProtocolOpenAIChatCompletions {
- t.Errorf("Protocol = %q, want %q", p.Protocol, ProtocolOpenAIChatCompletions)
- }
- if p.BaseURL != "https://api.x.ai/v1" {
- t.Errorf("BaseURL = %q, want %q", p.BaseURL, "https://api.x.ai/v1")
- }
- if p.EnvVar != "XAI_API_KEY" {
- t.Errorf("EnvVar = %q, want %q", p.EnvVar, "XAI_API_KEY")
- }
- if p.AuthHeader != "" {
- t.Errorf("AuthHeader = %q, want empty (OpenAI-compatible uses Bearer by default)", p.AuthHeader)
- }
- expectedModels := []string{
- "grok-4.6",
- "grok-4.5",
- "grok-4.3",
- }
- if len(p.Models) != len(expectedModels) {
- t.Fatalf("Models length = %d, want %d", len(p.Models), len(expectedModels))
- }
- for i, model := range expectedModels {
- if p.Models[i] != model {
- t.Errorf("Models[%d] = %q, want %q", i, p.Models[i], model)
- }
- }
-}
-
// TestProviders_AllProtocolsCanonical verifies every registry entry uses a
// canonical protocol constant — no stale "openai" / "anthropic" literals that
// would bypass NormalizeProtocol downstream.
diff --git a/internal/llm/responses_client.go b/internal/llm/responses_client.go
index 3613059e..522e2cc1 100644
--- a/internal/llm/responses_client.go
+++ b/internal/llm/responses_client.go
@@ -11,7 +11,6 @@ import (
openai "github.com/openai/openai-go/v3"
openaiopt "github.com/openai/openai-go/v3/option"
- "github.com/openai/openai-go/v3/packages/param"
"github.com/openai/openai-go/v3/responses"
)
@@ -225,11 +224,6 @@ func (c *OpenAIResponsesClient) buildResponsesParams(model string, req ChatReque
}
if len(tools) > 0 {
params.Tools = tools
- if req.ToolChoice == "required" {
- params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{
- OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsRequired),
- }
- }
}
if req.MaxTokens > 0 {
params.MaxOutputTokens = openai.Int(int64(req.MaxTokens))
diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go
index e9de0416..9c30ef78 100644
--- a/internal/llmloop/loop.go
+++ b/internal/llmloop/loop.go
@@ -40,15 +40,6 @@ type Deps struct {
// NewFileContent is the whole file and Diff is empty).
DiffLookup func(path string) *model.Diff
- // AllDiffs returns every diff this run reviews, for re-filing a comment
- // whose ExistingCode belongs to a different file than the one it was filed
- // against (diff.RelocateAcrossFiles). It is the reviewed set rather than
- // every parsed diff on purpose: re-filing a comment onto a path the run
- // excluded would point the reader at a file this review never covered.
- // When nil, cross-file re-filing is skipped and only same-file resolution
- // applies.
- AllDiffs func() []model.Diff
-
// NewRequestMeta builds the retry-report identity for one logical LLM
// request. Non-nil only for review: the retry report describes ocr review,
// and this Runner is shared with scan (internal/scan.Agent calls RunPerFile),
@@ -557,23 +548,8 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
if r.deps.DiffLookup != nil {
d = r.deps.DiffLookup(cm.Path)
}
- // Resolution order: the comment's own file, then a cross-file
- // search, then the LLM. The cross-file search precedes the LLM
- // because it needs the Agent's original ExistingCode, which the
- // LLM step overwrites; and it runs even when d is nil, since a
- // comment filed against a path this run holds no diff for is
- // exactly the case that search can still place.
- located := d != nil && diff.ResolveComment(cm, d)
- if !located && r.deps.AllDiffs != nil {
- from := cm.Path
- if to, ok := diff.RelocateAcrossFiles(cm, r.deps.AllDiffs()); ok {
- located = true
- r.RecordWarning("comment_refiled", to, fmt.Sprintf(
- "comment filed against %s describes code in %s; re-filed", from, to))
- }
- }
if d != nil {
- if !located && r.deps.Template.ReLocationTask != nil {
+ if !diff.ResolveComment(cm, d) && r.deps.Template.ReLocationTask != nil {
// rlStart stays ahead of prompt construction, which is
// where it sat when ReLocateComment built the messages
// itself — moving it would silently change what
diff --git a/internal/session/manifest.go b/internal/session/manifest.go
index 6a113b24..199f004b 100644
--- a/internal/session/manifest.go
+++ b/internal/session/manifest.go
@@ -265,6 +265,7 @@ type ManifestExecution struct {
ConfiguredConcurrency int `json:"configured_concurrency,omitempty"`
RuleConfigSHA256 string `json:"rule_config_sha256,omitempty"`
RuntimeConfigSHA256 string `json:"runtime_config_sha256,omitempty"`
+ PerFileMaxTokens int `json:"per_file_max_tokens,omitempty"`
}
// RunManifest is the immutable, versioned coverage snapshot of a single run.
diff --git a/internal/session/resume_identity.go b/internal/session/resume_identity.go
index e318b99d..a65dad66 100644
--- a/internal/session/resume_identity.go
+++ b/internal/session/resume_identity.go
@@ -14,6 +14,7 @@ type RunIdentity struct {
SourceArtifactSHA256 string // manifest input.source_artifact_sha256
RuleConfigSHA256 string // manifest execution.rule_config_sha256
RepositorySHA256 string // manifest repository.identity_sha256; empty when the repo has no remote
+ PerFileMaxTokens int // manifest execution.per_file_max_tokens
}
// ResumeRequest is what the resuming command is asking to do: run this input
@@ -29,6 +30,9 @@ type ResumeRequest struct {
Model string
ProviderExplicit bool
ModelExplicit bool
+ Incremental bool
+ ResolvedBase string
+ PerFileMaxTokens int
}
const resumeHint = "start a new review instead of resuming"
@@ -51,7 +55,7 @@ func (s *ResumeState) ValidateResume(req ResumeRequest) error {
if s == nil {
return nil
}
- if err := s.validateInputIdentity(req.Identity); err != nil {
+ if err := s.validateInputIdentity(req); err != nil {
return err
}
@@ -76,10 +80,11 @@ func (s *ResumeState) ValidateResume(req ResumeRequest) error {
// This runs once, at admission, and is never repeated during the run: the caller
// pins the run to the commit endpoints this comparison was made against (see
// agent.SealedInput), so a second comparison could only ever confirm the first.
-func (s *ResumeState) validateInputIdentity(id RunIdentity) error {
+func (s *ResumeState) validateInputIdentity(req ResumeRequest) error {
if s == nil {
return nil
}
+ id := req.Identity
m := s.Manifest
switch {
@@ -113,7 +118,17 @@ func (s *ResumeState) validateInputIdentity(id RunIdentity) error {
if m.Repository.IdentitySHA256 != id.RepositorySHA256 {
return fmt.Errorf("resume rejected: repository identity changed, so this is not the repository the parent run reviewed; %s", resumeHint)
}
- if m.Input.SourceArtifactSHA256 != id.SourceArtifactSHA256 {
+ if req.Incremental {
+ if m.Input.Mode != InputModeRange {
+ return fmt.Errorf("incremental resume is only supported for range mode, got %q; %s", m.Input.Mode, resumeHint)
+ }
+ if req.ResolvedBase == "" || m.Input.ResolvedBase != req.ResolvedBase {
+ return fmt.Errorf("the reviewed input base commit changed (parent: %q, requested: %q); %s", m.Input.ResolvedBase, req.ResolvedBase, resumeHint)
+ }
+ if req.PerFileMaxTokens != m.Execution.PerFileMaxTokens {
+ return fmt.Errorf("per-file max token ceiling changed (parent: %d, requested: %d); %s", m.Execution.PerFileMaxTokens, req.PerFileMaxTokens, resumeHint)
+ }
+ } else if m.Input.SourceArtifactSHA256 != id.SourceArtifactSHA256 {
return fmt.Errorf("resume rejected: the reviewed input changed since session %q — a ref may now point at a different commit, or the selected file set changed; %s", s.SessionID, resumeHint)
}
if m.Execution.RuleConfigSHA256 == "" {
diff --git a/internal/session/resume_identity_test.go b/internal/session/resume_identity_test.go
index 3a605202..cd06a17f 100644
--- a/internal/session/resume_identity_test.go
+++ b/internal/session/resume_identity_test.go
@@ -75,6 +75,62 @@ func TestValidateResume(t *testing.T) {
{
name: "identical input, provider and model is accepted",
},
+ {
+ name: "incremental resume with moved head and same base is accepted when Incremental is true",
+ req: func(r *ResumeRequest) {
+ r.Incremental = true
+ r.ResolvedBase = "base-commit-123"
+ r.PerFileMaxTokens = 1000
+ r.Identity.SourceArtifactSHA256 = "artifact-child-moved-head"
+ },
+ parent: func(m *RunManifest) {
+ m.Input.ResolvedBase = "base-commit-123"
+ m.Execution.PerFileMaxTokens = 1000
+ },
+ state: func(s *ResumeState) {
+ s.Manifest.Execution.PerFileMaxTokens = 1000
+ },
+ },
+ {
+ name: "incremental resume with moved head rejected when base differs",
+ req: func(r *ResumeRequest) {
+ r.Incremental = true
+ r.ResolvedBase = "base-commit-different"
+ r.PerFileMaxTokens = 1000
+ r.Identity.SourceArtifactSHA256 = "artifact-child-moved-head"
+ },
+ parent: func(m *RunManifest) {
+ m.Input.ResolvedBase = "base-commit-123"
+ m.Execution.PerFileMaxTokens = 1000
+ },
+ wantErr: "the reviewed input base commit changed",
+ },
+ {
+ name: "incremental resume with moved head rejected when per-file max tokens differs",
+ req: func(r *ResumeRequest) {
+ r.Incremental = true
+ r.ResolvedBase = "base-commit-123"
+ r.PerFileMaxTokens = 2000
+ r.Identity.SourceArtifactSHA256 = "artifact-child-moved-head"
+ },
+ parent: func(m *RunManifest) {
+ m.Input.ResolvedBase = "base-commit-123"
+ m.Execution.PerFileMaxTokens = 1000
+ },
+ wantErr: "per-file max token ceiling changed",
+ },
+ {
+ name: "incremental resume with moved head rejected when Incremental is false",
+ req: func(r *ResumeRequest) {
+ r.Incremental = false
+ r.ResolvedBase = "base-commit-123"
+ r.Identity.SourceArtifactSHA256 = "artifact-child-moved-head"
+ },
+ parent: func(m *RunManifest) {
+ m.Input.ResolvedBase = "base-commit-123"
+ },
+ wantErr: "the reviewed input changed",
+ },
{
name: "parent that completed nothing is still resumable",
// The parent's own coverage is irrelevant here: admission depends on
diff --git a/internal/stdout/stdout.go b/internal/stdout/stdout.go
index 58ed4da2..150ad192 100644
--- a/internal/stdout/stdout.go
+++ b/internal/stdout/stdout.go
@@ -41,25 +41,3 @@ func Quiet() func() {
mu.Unlock()
}
}
-
-// Swap replaces the stdout writer with replacement and returns a restore
-// function. It lets tests capture output written through Writer().
-// Usage:
-//
-// var buf bytes.Buffer
-// defer stdout.Swap(&buf)()
-//
-// Like Quiet, Swap acquires the package mutex for memory safety, but concurrent
-// swaps from multiple goroutines produce non-deterministic restore ordering.
-// Keep swapping and restoring on a single goroutine.
-func Swap(replacement io.Writer) func() {
- mu.Lock()
- old := w
- w = replacement
- mu.Unlock()
- return func() {
- mu.Lock()
- w = old
- mu.Unlock()
- }
-}
diff --git a/internal/stdout/stdout_test.go b/internal/stdout/stdout_test.go
index 9dfd6d7a..98130db8 100644
--- a/internal/stdout/stdout_test.go
+++ b/internal/stdout/stdout_test.go
@@ -4,7 +4,6 @@
package stdout
import (
- "bytes"
"io"
"os"
"testing"
@@ -32,41 +31,3 @@ func TestQuiet(t *testing.T) {
t.Error("expected Writer to be os.Stdout after restore")
}
}
-
-func TestSwap(t *testing.T) {
- var buf bytes.Buffer
- restore := Swap(&buf)
-
- if _, err := io.WriteString(Writer(), "captured"); err != nil {
- t.Fatalf("write to swapped writer failed: %v", err)
- }
- if got := buf.String(); got != "captured" {
- t.Errorf("expected swapped writer to capture %q, got %q", "captured", got)
- }
-
- restore()
-
- if w := Writer(); w != os.Stdout {
- t.Error("expected Writer to be os.Stdout after restore")
- }
-}
-
-func TestSwap_Composable(t *testing.T) {
- var buf bytes.Buffer
- outer := Swap(&buf)
-
- innerRestore := Quiet()
- if w := Writer(); w != io.Discard {
- t.Error("expected Writer to be io.Discard after nested Quiet()")
- }
- innerRestore()
-
- if w := Writer(); w != &buf {
- t.Error("expected Writer to be the swapped buffer after nested restore")
- }
- outer()
-
- if w := Writer(); w != os.Stdout {
- t.Error("expected Writer to be os.Stdout after outer restore")
- }
-}
diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go
index 3a30af52..34d3994a 100644
--- a/internal/telemetry/events.go
+++ b/internal/telemetry/events.go
@@ -68,36 +68,19 @@ func FormatDuration(dur time.Duration) string {
return dur.Round(time.Millisecond).String()
}
-// TraceSummary carries the metrics printed by PrintTraceSummary.
-type TraceSummary struct {
- FilesReviewed int64
- CommentsGenerated int64
- InputTokens int64
- OutputTokens int64
- TotalTokens int64
- CacheReadTokens int64
- CacheWriteTokens int64
- Duration time.Duration
- SessionID string
-}
-
// PrintTraceSummary prints a one-line summary of the review to stdout.
-// If SessionID is non-empty, an "[ocr] Session: " line follows the summary.
-func PrintTraceSummary(s TraceSummary) {
- elapsed := s.Duration.Round(time.Second).String()
- if s.InputTokens > 0 || s.OutputTokens > 0 {
+func PrintTraceSummary(filesReviewed, commentsGenerated int64, inputTokens, outputTokens, totalTokens int64, cacheReadTokens, cacheWriteTokens int64, duration time.Duration) {
+ elapsed := duration.Round(time.Second).String()
+ if inputTokens > 0 || outputTokens > 0 {
base := fmt.Sprintf("[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used (input: ~%d, output: ~%d)",
- s.FilesReviewed, s.CommentsGenerated, s.TotalTokens, s.InputTokens, s.OutputTokens)
- if s.CacheReadTokens > 0 || s.CacheWriteTokens > 0 {
- base += fmt.Sprintf(", cache(read: ~%d, write: ~%d)", s.CacheReadTokens, s.CacheWriteTokens)
+ filesReviewed, commentsGenerated, totalTokens, inputTokens, outputTokens)
+ if cacheReadTokens > 0 || cacheWriteTokens > 0 {
+ base += fmt.Sprintf(", cache(read: ~%d, write: ~%d)", cacheReadTokens, cacheWriteTokens)
}
fmt.Fprintf(stdout.Writer(), "%s, %s elapsed\n", base, elapsed)
} else {
fmt.Fprintf(stdout.Writer(), "[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used, %s elapsed\n",
- s.FilesReviewed, s.CommentsGenerated, s.TotalTokens, elapsed)
- }
- if s.SessionID != "" {
- fmt.Fprintf(stdout.Writer(), "[ocr] Session: %s\n", s.SessionID)
+ filesReviewed, commentsGenerated, totalTokens, elapsed)
}
}
diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go
index ef34d758..3f6b5dc2 100644
--- a/internal/telemetry/events_test.go
+++ b/internal/telemetry/events_test.go
@@ -19,8 +19,6 @@ import (
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
-
- "github.com/alibaba/open-code-review/internal/stdout"
)
func setupEnabledTelemetry(t *testing.T) {
@@ -126,88 +124,16 @@ func TestPhaseEvent_WithError(t *testing.T) {
PhaseEvent(ctx, "scan", "main.go", 500*time.Millisecond, errors.New("parse error"))
}
-func captureStdout(t *testing.T, fn func()) string {
- t.Helper()
- var buf bytes.Buffer
- restore := stdout.Swap(&buf)
- defer restore()
- fn()
- return buf.String()
-}
-
func TestPrintTraceSummary_WithTokenDetails(t *testing.T) {
- out := captureStdout(t, func() {
- PrintTraceSummary(TraceSummary{
- FilesReviewed: 5, CommentsGenerated: 10,
- InputTokens: 1000, OutputTokens: 200, TotalTokens: 1200,
- Duration: 3 * time.Second,
- })
- })
- want := "[ocr] Summary: 5 file(s) reviewed, 10 comment(s), ~1200 token(s) used (input: ~1000, output: ~200), 3s elapsed\n"
- if out != want {
- t.Errorf("PrintTraceSummary output = %q, want %q", out, want)
- }
+ PrintTraceSummary(5, 10, 1000, 200, 1200, 0, 0, 3*time.Second)
}
func TestPrintTraceSummary_WithCacheTokens(t *testing.T) {
- out := captureStdout(t, func() {
- PrintTraceSummary(TraceSummary{
- FilesReviewed: 3, CommentsGenerated: 2,
- InputTokens: 500, OutputTokens: 100, TotalTokens: 600,
- CacheReadTokens: 200, CacheWriteTokens: 50,
- Duration: 2 * time.Second,
- })
- })
- want := "[ocr] Summary: 3 file(s) reviewed, 2 comment(s), ~600 token(s) used (input: ~500, output: ~100), cache(read: ~200, write: ~50), 2s elapsed\n"
- if out != want {
- t.Errorf("PrintTraceSummary output = %q, want %q", out, want)
- }
+ PrintTraceSummary(3, 2, 500, 100, 600, 200, 50, 2*time.Second)
}
func TestPrintTraceSummary_NoTokenDetails(t *testing.T) {
- out := captureStdout(t, func() {
- PrintTraceSummary(TraceSummary{
- FilesReviewed: 2, CommentsGenerated: 1,
- TotalTokens: 500,
- Duration: 1 * time.Second,
- })
- })
- want := "[ocr] Summary: 2 file(s) reviewed, 1 comment(s), ~500 token(s) used, 1s elapsed\n"
- if out != want {
- t.Errorf("PrintTraceSummary output = %q, want %q", out, want)
- }
-}
-
-func TestPrintTraceSummary_WithSessionID(t *testing.T) {
- out := captureStdout(t, func() {
- PrintTraceSummary(TraceSummary{
- FilesReviewed: 5, CommentsGenerated: 10,
- InputTokens: 1000, OutputTokens: 200, TotalTokens: 1200,
- Duration: 3 * time.Second,
- SessionID: "3a7f2b1c-9d4e-4f8a-b2c1-6e7f8a9b0c1d",
- })
- })
- want := "[ocr] Summary: 5 file(s) reviewed, 10 comment(s), ~1200 token(s) used (input: ~1000, output: ~200), 3s elapsed\n" +
- "[ocr] Session: 3a7f2b1c-9d4e-4f8a-b2c1-6e7f8a9b0c1d\n"
- if out != want {
- t.Errorf("PrintTraceSummary output = %q, want %q", out, want)
- }
-}
-
-func TestPrintTraceSummary_WithoutSessionID(t *testing.T) {
- // An empty session ID exercises the omit path: no session line is printed.
- // The empty case arises when session persistence is unavailable, not from
- // preview mode (preview does not reach PrintTraceSummary).
- out := captureStdout(t, func() {
- PrintTraceSummary(TraceSummary{
- FilesReviewed: 2, CommentsGenerated: 1,
- TotalTokens: 500,
- Duration: 1 * time.Second,
- })
- })
- if strings.Contains(out, "Session:") {
- t.Errorf("expected no session line for empty session ID, got %q", out)
- }
+ PrintTraceSummary(2, 1, 0, 0, 500, 0, 0, 1*time.Second)
}
func TestPrintToolCallStarted_WithArgs(t *testing.T) {
diff --git a/internal/viewer/store.go b/internal/viewer/store.go
index d2f04d46..d85abd1d 100644
--- a/internal/viewer/store.go
+++ b/internal/viewer/store.go
@@ -432,8 +432,6 @@ func LoadSession(root, encodedRepo, sessionID string) (*ViewSession, error) {
info := ToolCallInfo{Name: name, Arguments: args}
if name == "task_done" {
info.Ok = taskDoneSucceeded(args)
- } else if name == "report_incorrect_comments" || name == "approve_all_comments" {
- info.Ok = true
}
card.ToolCalls = append(card.ToolCalls, info)
}
diff --git a/pages/src/assets/icons/apple.svg b/pages/src/assets/icons/apple.svg
deleted file mode 100644
index 49ac0e7f..00000000
--- a/pages/src/assets/icons/apple.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/pages/src/assets/icons/linux.svg b/pages/src/assets/icons/linux.svg
deleted file mode 100644
index 4664f736..00000000
--- a/pages/src/assets/icons/linux.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
diff --git a/pages/src/assets/icons/macports.svg b/pages/src/assets/icons/macports.svg
deleted file mode 100644
index d2f83920..00000000
--- a/pages/src/assets/icons/macports.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/pages/src/assets/icons/windows.svg b/pages/src/assets/icons/windows.svg
deleted file mode 100644
index 7b0d29b4..00000000
--- a/pages/src/assets/icons/windows.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/pages/src/components/HeroSection.test.tsx b/pages/src/components/HeroSection.test.tsx
deleted file mode 100644
index 7a15ee78..00000000
--- a/pages/src/components/HeroSection.test.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright 2026 alibaba/open-code-review Contributors
-
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { MemoryRouter } from 'react-router-dom';
-import { LanguageProvider } from '../i18n';
-import HeroSection from './HeroSection';
-
-function renderHero() {
- render(
-
-
-
-
- ,
- );
-}
-
-// The panel is found through the id that `aria-controls` already points at, so
-// the test leans on the accessibility wiring instead of a test-only hook.
-const panel = () => document.getElementById('install-more-panel');
-const trigger = () => screen.getByRole('button', { name: /More|MacPorts/i });
-
-describe('HeroSection install channels', () => {
- it('starts on the first channel with the panel closed', () => {
- renderHero();
- expect(screen.getByText('npm i -g @alibaba-group/open-code-review')).toBeTruthy();
- expect(panel()).toBeNull();
- });
-
- it('picking an overflow channel swaps the command and closes the panel', async () => {
- const user = userEvent.setup();
- renderHero();
-
- await user.click(trigger());
- expect(panel()).not.toBeNull();
-
- await user.click(screen.getByRole('button', { name: /MacPorts/i }));
-
- expect(screen.getByText('sudo port install open-code-review')).toBeTruthy();
- expect(panel()).toBeNull();
- });
-
- it('closes when a primary tab is clicked', async () => {
- const user = userEvent.setup();
- renderHero();
-
- await user.click(trigger());
- await user.click(screen.getByRole('button', { name: /Homebrew/i }));
-
- expect(screen.getByText('brew install open-code-review')).toBeTruthy();
- expect(panel()).toBeNull();
- });
-
- // Keyboard activation dispatches `click` with no preceding `mousedown`, so
- // this does not go through the same path as the test above.
- it('closes when a primary tab is activated by keyboard', async () => {
- const user = userEvent.setup();
- renderHero();
-
- await user.click(trigger());
- screen.getByRole('button', { name: /Homebrew/i }).focus();
- await user.keyboard('{Enter}');
-
- expect(screen.getByText('brew install open-code-review')).toBeTruthy();
- expect(panel()).toBeNull();
- });
-
- it('closes on Escape and on an outside click', async () => {
- const user = userEvent.setup();
- renderHero();
-
- await user.click(trigger());
- await user.keyboard('{Escape}');
- expect(panel()).toBeNull();
-
- await user.click(trigger());
- await user.click(document.body);
- expect(panel()).toBeNull();
- });
-
- it('reflects the selected overflow channel on the trigger', async () => {
- const user = userEvent.setup();
- renderHero();
- expect(screen.getByRole('button', { name: /^More$/i })).toBeTruthy();
-
- await user.click(trigger());
- await user.click(screen.getByRole('button', { name: /MacPorts/i }));
-
- const collapsed = screen.getByRole('button', { name: /MacPorts/i });
- expect(collapsed.getAttribute('aria-expanded')).toBe('false');
- expect(screen.queryByRole('button', { name: /^More$/i })).toBeNull();
- });
-});
diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx
index d657ace1..c3b96962 100644
--- a/pages/src/components/HeroSection.tsx
+++ b/pages/src/components/HeroSection.tsx
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
-import React, { Suspense, useCallback, useState, useEffect, useRef } from 'react';
+import React, { Suspense, useCallback, useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from '../i18n';
@@ -9,12 +9,7 @@ import { useResponsive } from '../hooks/useResponsive';
import ErrorBoundary from './ErrorBoundary';
import npmIcon from '../assets/icons/npm.svg';
import brewIcon from '../assets/icons/brew.svg';
-import macportsIcon from '../assets/icons/macports.svg';
-import appleIcon from '../assets/icons/apple.svg';
-import linuxIcon from '../assets/icons/linux.svg';
-import windowsIcon from '../assets/icons/windows.svg';
import copyIcon from '../assets/icons/icon-copy.svg';
-import chevronDownIcon from '../assets/icons/icon-chevron-down.svg';
const ColorBends = React.lazy(() => import(/* webpackChunkName: "color-bends" */ './ColorBends'));
@@ -127,26 +122,11 @@ const terminalLines = [
{ num: 12, content: | }, // allow-non-english: fullwidth bar renders the terminal cursor
];
-interface InstallChannel {
- key: string;
- labelKey: string;
- cmd: string;
- icons: string[];
- primary: boolean;
-}
-
-const INSTALL_CHANNELS: InstallChannel[] = [
- { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon], primary: true },
- { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon], primary: true },
- { key: 'macos', labelKey: 'hero.installMacOS', cmd: 'curl -fsSL https://open-codereview.ai/install.sh | sh', icons: [appleIcon], primary: false },
- { key: 'linux', labelKey: 'hero.installLinux', cmd: 'curl -fsSL https://open-codereview.ai/install.sh | sh', icons: [linuxIcon], primary: false },
- { key: 'windows', labelKey: 'hero.installWindows', cmd: 'irm https://open-codereview.ai/install.ps1 | iex', icons: [windowsIcon], primary: false },
- { key: 'macports', labelKey: 'hero.installMacPorts', cmd: 'sudo port install open-code-review', icons: [macportsIcon], primary: false },
+const INSTALL_CHANNELS = [
+ { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon] },
+ { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon] },
];
-const PRIMARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => ch.primary);
-const SECONDARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => !ch.primary);
-
const HeroSection: React.FC = () => {
const { t } = useTranslation();
const { isMobile, isTablet, isDesktop } = useResponsive();
@@ -154,12 +134,7 @@ const HeroSection: React.FC = () => {
const [toastVisible, setToastVisible] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [showShaderBackground, setShowShaderBackground] = useState(false);
- const [activeChannelKey, setActiveChannelKey] = useState(INSTALL_CHANNELS[0].key);
- const [menuOpen, setMenuOpen] = useState(false);
- const menuRef = useRef(null);
-
- const activeChannel = INSTALL_CHANNELS.find((ch) => ch.key === activeChannelKey) ?? INSTALL_CHANNELS[0];
- const activeIsSecondary = !activeChannel.primary;
+ const [activeChannel, setActiveChannel] = useState(0);
const showToast = (message: string) => {
setToastMessage(message);
@@ -201,24 +176,6 @@ const HeroSection: React.FC = () => {
return () => clearTimeout(timer);
}, [toastVisible]);
- useEffect(() => {
- if (!menuOpen) return;
- const handlePointerDown = (e: MouseEvent | TouchEvent) => {
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
- };
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') setMenuOpen(false);
- };
- document.addEventListener('mousedown', handlePointerDown);
- document.addEventListener('touchstart', handlePointerDown);
- document.addEventListener('keydown', handleKeyDown);
- return () => {
- document.removeEventListener('mousedown', handlePointerDown);
- document.removeEventListener('touchstart', handlePointerDown);
- document.removeEventListener('keydown', handleKeyDown);
- };
- }, [menuOpen]);
-
useEffect(() => {
// Wait until after the first paint before loading the heavy shader chunk.
let secondFrame: number | undefined;
@@ -252,7 +209,7 @@ const HeroSection: React.FC = () => {
minHeight: isMobile ? 600 : isTablet ? 700 : 680,
paddingBottom: isMobile ? 60 : 80,
position: 'relative',
- overflow: 'visible',
+ overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -368,140 +325,33 @@ const HeroSection: React.FC = () => {
{/* Install channels — tab switcher */}
-
-
- {PRIMARY_CHANNELS.map((ch) => {
- const isActive = ch.key === activeChannelKey;
- return (
-
- );
- })}
-
- {/* Overflow channels live behind a "More" trigger so the row stops
- growing each time a new channel is added. */}
-