From b3b4f238a36033fa138591c4ea8efddfd974e2e2 Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 21:30:57 +0800
Subject: [PATCH 01/87] docs: add security assurance case and use signed tags
Add ASSURANCE_CASE.md covering threat model, secure design principles
(Saltzer & Schroeder), OWASP/CWE countermeasures, and automated
verification. Switch tag command from annotated (-a) to signed (-s) to
match the assurance case's integrity claims.
---
.claude/commands/tag.md | 2 +-
ASSURANCE_CASE.md | 109 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 1 deletion(-)
create mode 100644 ASSURANCE_CASE.md
diff --git a/.claude/commands/tag.md b/.claude/commands/tag.md
index 2a179e1..a1a1c48 100644
--- a/.claude/commands/tag.md
+++ b/.claude/commands/tag.md
@@ -25,7 +25,7 @@ Create an annotated git tag with an auto-generated summary of changes since the
Run:
```bash
-git tag -a -m ""
+git tag -s -m ""
```
Report the created tag and its message to the user.
diff --git a/ASSURANCE_CASE.md b/ASSURANCE_CASE.md
new file mode 100644
index 0000000..ccd924f
--- /dev/null
+++ b/ASSURANCE_CASE.md
@@ -0,0 +1,109 @@
+# Security Assurance Case
+
+This document provides a security assurance case for Open Code Review (OCR), justifying that security requirements are met through secure design principles and countermeasures against common implementation weaknesses.
+
+## Threat Model
+
+### System Description
+
+OCR is a CLI tool that:
+
+1. Reads git diff output from a local repository.
+2. Sends code diffs to a configured LLM provider (OpenAI, Anthropic, etc.) via HTTPS.
+3. Receives review comments from the LLM and presents them to the user.
+4. Optionally serves a local web viewer for browsing review session history.
+
+### Actors
+
+| Actor | Trust Level |
+|-------|-------------|
+| Local user | Trusted — invokes the CLI with full control over configuration |
+| LLM provider API | Semi-trusted — responses are validated before use |
+| Git repository | Semi-trusted — diffs may contain adversarial content |
+| Network | Untrusted — all communication uses TLS |
+| Web browser (viewer) | Untrusted — may be exploited via DNS rebinding |
+
+### Trust Boundaries
+
+```
+┌──────────────────────────────────────────────────┐
+│ User Machine (Trusted Zone) │
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
+│ │ Git Repo │───▶│ OCR CLI │───▶│ Local File │ │
+│ │ (diffs) │ │ (core) │ │ (output) │ │
+│ └──────────┘ └────┬─────┘ └────────────┘ │
+│ Trust ────────▶│◀──────── Trust │
+│ Boundary 1 │ Boundary 3 │
+│ │ │
+│ ┌────┴─────┐ │
+│ │ Viewer │◀── Trust Boundary 4 │
+│ │ (HTTP) │ (Browser access) │
+│ └──────────┘ │
+└───────────────────────┼───────────────────────────┘
+ Trust ──────▶│◀────── Boundary 2
+ │
+ ┌─────────┴──────────┐
+ │ LLM Provider API │
+ │ (HTTPS only) │
+ └────────────────────┘
+```
+
+1. **Git → CLI**: Diff content may contain crafted payloads. Parsed with strict format validation.
+2. **CLI → LLM API**: API keys transmitted over HTTPS only. Responses validated before use.
+3. **CLI → Local output**: File writes constrained to the working directory.
+4. **Browser → Viewer**: Host-header allowlist enforces access control; blocks DNS rebinding.
+
+### Threat Summary
+
+| ID | Threat | Boundary | Mitigation |
+|----|--------|----------|------------|
+| T1 | Command injection via crafted diff content | 1 | All external commands are `git` only, with hardcoded subcommands; no shell expansion; `--end-of-options` used to prevent flag injection |
+| T2 | API key leakage | 2 | Keys read from environment variables only; never logged, written to output files, or transmitted beyond the configured LLM endpoint |
+| T3 | Path traversal via LLM-suggested file paths | 3 | `pathutil.WithinBase()` validates all file paths against the repository root, both before and after symlink resolution |
+| T4 | DNS rebinding against local viewer | 4 | Host-header allowlist rejects requests from non-loopback origins; configurable via `OCR_VIEWER_ALLOWED_HOSTS` |
+| T5 | Man-in-the-middle on API communication | 2 | Go's `net/http` enforces TLS 1.2+ with full certificate verification by default; `InsecureSkipVerify` is never set |
+| T6 | Malicious LLM response | 2 | JSON schema validation on response structure; line number bounds checking against actual diff ranges |
+| T7 | Dependency vulnerabilities | All | `govulncheck` runs in CI; Dependabot monitors for updates; `go.sum` provides integrity verification |
+
+## Secure Design Principles
+
+The following analysis maps [Saltzer & Schroeder's design principles](https://ieeexplore.ieee.org/document/1451869) to the project's implementation.
+
+| Principle | How Applied |
+|-----------|-------------|
+| **Least privilege** | `CGO_ENABLED=0` eliminates C library attack surface. The CLI requires no elevated permissions. No network listeners except the opt-in viewer. |
+| **Fail-safe defaults** | API keys must be explicitly provided via environment variables. The viewer binds to localhost by default; non-loopback hosts require explicit allowlisting. |
+| **Complete mediation** | Every viewer HTTP request is checked against the host allowlist (`internal/viewer/hostguard.go`). Every file path from agent tools is validated against the repository root (`internal/tool/filereader.go:98`, `internal/pathutil/path.go`). |
+| **Economy of mechanism** | External process execution is limited to `git` with hardcoded subcommands — no shell invocation, no arbitrary command execution. |
+| **Open design** | Fully open-source (Apache-2.0). Security relies on TLS, not obscurity. |
+| **Separation of privilege** | API authentication (keys) is separated from configuration (files). The viewer's host guard is a distinct middleware layer. |
+| **Least common mechanism** | Each review session writes to its own JSONL file. No shared state between sessions. |
+| **Psychological acceptability** | Security defaults (HTTPS, localhost binding, host allowlist) require no user configuration. Overrides (`OCR_VIEWER_ALLOWED_HOSTS`) are explicit and documented. |
+
+## Countermeasures Against Common Weaknesses
+
+The following maps [OWASP Top 10](https://owasp.org/www-project-top-ten/) and [CWE/SANS Top 25](https://cwe.mitre.org/top25/) categories to the project.
+
+| Weakness | Applicability | Countermeasure |
+|----------|---------------|----------------|
+| **A03:2021 Injection** (CWE-78 OS Command Injection) | All `exec.Command` calls use `git` with explicit argument lists — no shell interpolation. `--end-of-options` prevents flag injection. | Mitigated |
+| **A01:2021 Broken Access Control** (CWE-22 Path Traversal) | Agent file-read tool validates paths with `pathutil.WithinBase()` before and after symlink resolution (`internal/tool/filereader.go:91-112`). | Mitigated |
+| **A02:2021 Cryptographic Failures** | All API communication uses HTTPS/TLS 1.2+. Go's default TLS configuration is used without weakening. `InsecureSkipVerify` is never set. | Mitigated |
+| **A07:2021 Auth Failures** (CWE-798 Hard-coded Credentials) | API keys are read exclusively from environment variables, never embedded in code or config files, never logged. | Mitigated |
+| **A05:2021 Security Misconfiguration** | Secure defaults: localhost-only viewer, HTTPS-only API calls, `CGO_ENABLED=0`. The `go vet` and `govulncheck` tools run in CI. | Mitigated |
+| **A06:2021 Vulnerable Components** (CWE-1104) | Dependabot monitors Go modules and GitHub Actions. `govulncheck` runs on every push/PR. Dependencies are locked via `go.sum`. | Mitigated |
+| **A08:2021 Software Integrity** | Release binaries include SHA-256 checksums. Release tags are cryptographically signed (SSH). `CGO_ENABLED=0` produces static binaries with no external shared library dependencies. | Mitigated |
+| **A09:2021 Logging Failures** | API keys and sensitive headers are excluded from all log output and telemetry. | Mitigated |
+| **A10:2021 SSRF** | The CLI only makes outbound requests to user-configured LLM API endpoints. The viewer does not make outbound requests. | Not applicable |
+| **CWE-416 Use After Free / CWE-787 Out-of-bounds Write** | Go is a memory-safe language. `CGO_ENABLED=0` eliminates C memory risks. Race detector (`-race`) runs in CI. | Not applicable (memory-safe language) |
+
+## Automated Verification
+
+| Check | Tool | When |
+|-------|------|------|
+| Static analysis | `go vet` | Every push and PR (CI) |
+| Known vulnerability scan | `govulncheck` | Every push and PR (CI) |
+| Data race detection | `go test -race` | Every push and PR (CI) |
+| Dependency monitoring | Dependabot | Continuous |
+| Build integrity | `CGO_ENABLED=0`, `go.sum` checksums | Every build |
From e1a6a404ba875eb58e8e7e95084f95e96a1a30f8 Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 22:18:03 +0800
Subject: [PATCH 02/87] feat(ci): add Sigstore attestation for release
artifacts
Add build provenance attestation to the release workflow using
actions/attest-build-provenance with OIDC keyless signing.
Document release signature verification in SECURITY.md.
---
.github/workflows/release.yml | 11 +++++++++++
SECURITY.md | 16 ++++++++++++++++
internal/llm/resolver.go | 2 +-
3 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d75b028..1536910 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,6 +58,10 @@ jobs:
release:
needs: build
runs-on: self-hosted
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
container:
image: ubuntu:24.04
steps:
@@ -145,6 +149,13 @@ jobs:
opencodereview-*
sha256sum.txt
+ - name: Attest release artifacts
+ uses: actions/attest-build-provenance@v2
+ with:
+ subject-path: |
+ opencodereview-*
+ sha256sum.txt
+
npm-publish:
needs: release
runs-on: self-hosted
diff --git a/SECURITY.md b/SECURITY.md
index 2fd3a65..5701307 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -43,6 +43,22 @@ Out of scope:
- Denial-of-service attacks that require local access.
- Social engineering attacks.
+## Release Signatures
+
+All release binaries and checksums are signed using [GitHub Artifact Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) (Sigstore). Signatures are keyless — backed by GitHub Actions OIDC, with no long-lived private key. Version tags are signed with SSH keys via `git tag -s`.
+
+To verify a downloaded binary:
+
+```bash
+gh attestation verify opencodereview-linux-amd64 --repo alibaba/open-code-review
+```
+
+To verify a version tag:
+
+```bash
+git tag -v v1.6.4
+```
+
## Recognition
We appreciate the security research community's efforts. Reporters who follow responsible disclosure will be credited in the release notes (unless they prefer to remain anonymous).
diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go
index ae53ddf..8ed011d 100644
--- a/internal/llm/resolver.go
+++ b/internal/llm/resolver.go
@@ -491,7 +491,7 @@ var reservedHeaders = map[string]bool{
// ParseExtraHeaders parses a string of comma-separated key=value pairs into a dictionary.
// Values may be double-quoted to include commas, e.g. X-Forwarded-For="1.2.3.4,5.6.7.8".
-// Reserved header names (authorization, x-api-key, content-type, user-agent) are rejected
+// Reserved header names (authorization, x-api-key, content-type, user-agent) are rejected
// to prevent accidental override of auth or content-type set by the SDK.
func ParseExtraHeaders(raw string) (map[string]string, error) {
if raw == "" {
From 98fe309f03801772361970a423a6d50cb4de3e5e Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 22:41:35 +0800
Subject: [PATCH 03/87] test: add unit tests for pure logic functions across 6
packages
Add table-driven tests for pure computation functions with no external
dependencies, improving overall coverage from 45.9% to 48.8%.
Covered functions:
- suggestdiff: ComputeLineDiff (LCS algorithm)
- tool: CommentCollector, DiffMap, FileReadDiff, Registry, ParseReviewMode, scanLines
- llmloop: CountMessagesTokens, groupIntoRounds, partitionMessages, StripMarkdownFences
- agent: buildFilterCommentsJSON, parseFilterResponse, extFromPath, formatToolDefs, BuildToolDefs
- viewer: truncateText, formatDuration, formatTime
---
internal/agent/agent_test.go | 304 ++++++++++++++++++++++++
internal/llmloop/compression_test.go | 177 ++++++++++++++
internal/suggestdiff/diff_test.go | 127 ++++++++++
internal/tool/comment_collector_test.go | 152 ++++++++++++
internal/tool/definitions_test.go | 109 +++++++++
internal/tool/file_read_diff_test.go | 106 +++++++++
internal/tool/filereader_test.go | 139 +++++++++++
internal/viewer/server_test.go | 73 ++++++
8 files changed, 1187 insertions(+)
create mode 100644 internal/agent/agent_test.go
create mode 100644 internal/llmloop/compression_test.go
create mode 100644 internal/suggestdiff/diff_test.go
create mode 100644 internal/tool/comment_collector_test.go
create mode 100644 internal/tool/definitions_test.go
create mode 100644 internal/tool/file_read_diff_test.go
create mode 100644 internal/tool/filereader_test.go
create mode 100644 internal/viewer/server_test.go
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
new file mode 100644
index 0000000..a3b65b9
--- /dev/null
+++ b/internal/agent/agent_test.go
@@ -0,0 +1,304 @@
+package agent
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/toolsconfig"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+func TestBuildFilterCommentsJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ comments []model.LlmComment
+ wantIDs []string
+ }{
+ {
+ name: "empty slice",
+ comments: nil,
+ wantIDs: nil,
+ },
+ {
+ name: "single comment",
+ comments: []model.LlmComment{
+ {Content: "fix this", ExistingCode: "old code"},
+ },
+ wantIDs: []string{"c-0"},
+ },
+ {
+ name: "multiple comments sequential IDs",
+ comments: []model.LlmComment{
+ {Content: "issue A"},
+ {Content: "issue B", ExistingCode: "existing"},
+ {Content: "issue C"},
+ },
+ wantIDs: []string{"c-0", "c-1", "c-2"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildFilterCommentsJSON(tt.comments)
+
+ var items []struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ ExistingCode string `json:"existing_code,omitempty"`
+ }
+ if err := json.Unmarshal([]byte(got), &items); err != nil {
+ t.Fatalf("invalid JSON: %v\nraw: %s", err, got)
+ }
+
+ if len(items) != len(tt.comments) {
+ t.Fatalf("len = %d, want %d", len(items), len(tt.comments))
+ }
+
+ for i, item := range items {
+ if tt.wantIDs != nil && item.ID != tt.wantIDs[i] {
+ t.Errorf("items[%d].ID = %q, want %q", i, item.ID, tt.wantIDs[i])
+ }
+ if item.Content != tt.comments[i].Content {
+ t.Errorf("items[%d].Content = %q, want %q", i, item.Content, tt.comments[i].Content)
+ }
+ if item.ExistingCode != tt.comments[i].ExistingCode {
+ t.Errorf("items[%d].ExistingCode = %q, want %q", i, item.ExistingCode, tt.comments[i].ExistingCode)
+ }
+ }
+ })
+ }
+}
+
+func TestParseFilterResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ total int
+ wantSet map[int]struct{}
+ }{
+ {
+ name: "valid JSON array",
+ raw: `["c-0","c-2","c-4"]`,
+ total: 5,
+ wantSet: map[int]struct{}{0: {}, 2: {}, 4: {}},
+ },
+ {
+ name: "markdown fenced JSON",
+ raw: "```json\n[\"c-1\"]\n```",
+ total: 3,
+ wantSet: map[int]struct{}{1: {}},
+ },
+ {
+ name: "out-of-range indices ignored",
+ raw: `["c-0","c-10","c-99"]`,
+ total: 5,
+ wantSet: map[int]struct{}{0: {}},
+ },
+ {
+ name: "negative index ignored",
+ raw: `["c--1","c-0"]`,
+ total: 2,
+ wantSet: map[int]struct{}{0: {}},
+ },
+ {
+ name: "invalid ID format ignored",
+ raw: `["x-0","c-abc","c-1"]`,
+ total: 3,
+ wantSet: map[int]struct{}{1: {}},
+ },
+ {
+ name: "invalid JSON returns nil",
+ raw: `not json`,
+ total: 5,
+ wantSet: nil,
+ },
+ {
+ name: "empty array",
+ raw: `[]`,
+ total: 5,
+ wantSet: map[int]struct{}{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseFilterResponse(tt.raw, 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{})
+
+ tests := []struct {
+ path string
+ want string
+ }{
+ {"main.go", ".go"},
+ {"src/app.tsx", ".tsx"},
+ {"path/to/FILE.JSON", ".json"},
+ {"Makefile", ""},
+ {".gitignore", ""},
+ {"dir/.hidden", ""},
+ {"archive.tar.gz", ".gz"},
+ {"no-ext", ""},
+ {"path/to/", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ got := a.extFromPath(tt.path)
+ if got != tt.want {
+ t.Errorf("extFromPath(%q) = %q, want %q", tt.path, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFormatToolDefs(t *testing.T) {
+ t.Run("empty defs returns empty string", func(t *testing.T) {
+ got := formatToolDefs(nil)
+ if got != "" {
+ t.Errorf("expected empty, got %q", got)
+ }
+ })
+
+ t.Run("single tool with parameters", func(t *testing.T) {
+ defs := []llm.ToolDef{
+ {
+ Type: "function",
+ Function: llm.FunctionDef{
+ Name: "file_read",
+ Description: "Read a file from the repository",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "File path to read",
+ },
+ "start_line": map[string]any{
+ "type": "integer",
+ "description": "Starting line number",
+ },
+ },
+ "required": []any{"path"},
+ },
+ },
+ },
+ }
+ got := formatToolDefs(defs)
+ if !strings.Contains(got, "### Available Tools") {
+ t.Error("missing header")
+ }
+ if !strings.Contains(got, "**file_read**") {
+ t.Error("missing tool name")
+ }
+ if !strings.Contains(got, "Read a file from the repository") {
+ t.Error("missing description")
+ }
+ if !strings.Contains(got, "path") {
+ t.Error("missing parameter name")
+ }
+ if !strings.Contains(got, "(required)") {
+ t.Error("missing required marker")
+ }
+ })
+
+ t.Run("tool without parameters", func(t *testing.T) {
+ defs := []llm.ToolDef{
+ {
+ Type: "function",
+ Function: llm.FunctionDef{
+ Name: "task_done",
+ Description: "Signal task completion",
+ Parameters: map[string]any{},
+ },
+ },
+ }
+ got := formatToolDefs(defs)
+ if !strings.Contains(got, "**task_done**") {
+ t.Error("missing tool name")
+ }
+ if strings.Contains(got, "Parameters:") {
+ t.Error("should not show Parameters section for empty params")
+ }
+ })
+
+ t.Run("multiple tools", func(t *testing.T) {
+ defs := []llm.ToolDef{
+ {Type: "function", Function: llm.FunctionDef{Name: "tool_a", Description: "desc a"}},
+ {Type: "function", Function: llm.FunctionDef{Name: "tool_b", Description: "desc b"}},
+ }
+ got := formatToolDefs(defs)
+ if !strings.Contains(got, "tool_a") || !strings.Contains(got, "tool_b") {
+ t.Errorf("missing tools in output: %s", got)
+ }
+ })
+}
+
+func TestBuildToolDefs(t *testing.T) {
+ funcDef := json.RawMessage(`{"name":"test_tool","description":"a tool","parameters":{}}`)
+
+ entries := []toolsconfig.ToolConfigEntry{
+ {Name: "plan_only", PlanTask: true, MainTask: false, Definition: funcDef},
+ {Name: "main_only", PlanTask: false, MainTask: true, Definition: funcDef},
+ {Name: "both", PlanTask: true, MainTask: true, Definition: funcDef},
+ {Name: "neither", PlanTask: false, MainTask: false, Definition: funcDef},
+ }
+
+ t.Run("planOnly=true returns plan_task tools", func(t *testing.T) {
+ defs := BuildToolDefs(entries, true)
+ if len(defs) != 2 {
+ t.Fatalf("expected 2 defs, got %d", len(defs))
+ }
+ names := make(map[string]bool)
+ for _, d := range defs {
+ names[d.Function.Name] = true
+ }
+ if !names["test_tool"] {
+ t.Error("expected test_tool in plan defs")
+ }
+ })
+
+ t.Run("planOnly=false returns main_task tools", func(t *testing.T) {
+ defs := BuildToolDefs(entries, false)
+ if len(defs) != 2 {
+ t.Fatalf("expected 2 defs, got %d", len(defs))
+ }
+ })
+
+ t.Run("invalid definition JSON is skipped", func(t *testing.T) {
+ bad := []toolsconfig.ToolConfigEntry{
+ {Name: "bad", PlanTask: true, MainTask: true, Definition: json.RawMessage(`{invalid}`)},
+ {Name: "good", PlanTask: true, MainTask: true, Definition: funcDef},
+ }
+ defs := BuildToolDefs(bad, true)
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 def (bad skipped), got %d", len(defs))
+ }
+ })
+
+ t.Run("empty entries returns nil", func(t *testing.T) {
+ defs := BuildToolDefs(nil, true)
+ if defs != nil {
+ t.Errorf("expected nil, got %v", defs)
+ }
+ })
+}
diff --git a/internal/llmloop/compression_test.go b/internal/llmloop/compression_test.go
new file mode 100644
index 0000000..161da11
--- /dev/null
+++ b/internal/llmloop/compression_test.go
@@ -0,0 +1,177 @@
+package llmloop
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
+)
+
+func msg(role, text string) llm.Message {
+ return llm.NewTextMessage(role, text)
+}
+
+func TestCountMessagesTokens(t *testing.T) {
+ msgs := []llm.Message{
+ msg("user", "hello world"),
+ msg("assistant", "hi there"),
+ }
+ got := CountMessagesTokens(msgs)
+ if got <= 0 {
+ t.Errorf("expected positive token count, got %d", got)
+ }
+}
+
+func TestCountMessagesTokens_Empty(t *testing.T) {
+ got := CountMessagesTokens(nil)
+ if got != 0 {
+ t.Errorf("expected 0 for nil, got %d", got)
+ }
+}
+
+func TestGroupIntoRounds(t *testing.T) {
+ messages := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ msg("assistant", "resp1"),
+ msg("tool", "result1"),
+ msg("tool", "result2"),
+ msg("assistant", "resp2"),
+ msg("tool", "result3"),
+ msg("assistant", "resp3"),
+ }
+
+ rounds := groupIntoRounds(messages, 2)
+ if len(rounds) != 3 {
+ t.Fatalf("expected 3 rounds, got %d", len(rounds))
+ }
+
+ if rounds[0].assistantIdx != 2 {
+ t.Errorf("round[0].assistantIdx = %d, want 2", rounds[0].assistantIdx)
+ }
+ if len(rounds[0].toolIdxs) != 2 {
+ t.Errorf("round[0] should have 2 tool messages, got %d", len(rounds[0].toolIdxs))
+ }
+ if rounds[1].assistantIdx != 5 {
+ t.Errorf("round[1].assistantIdx = %d, want 5", rounds[1].assistantIdx)
+ }
+ if rounds[2].assistantIdx != 7 {
+ t.Errorf("round[2].assistantIdx = %d, want 7", rounds[2].assistantIdx)
+ }
+ if len(rounds[2].toolIdxs) != 0 {
+ t.Errorf("round[2] should have 0 tool messages")
+ }
+}
+
+func TestGroupIntoRounds_NoAssistant(t *testing.T) {
+ messages := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ msg("user", "another"),
+ }
+ rounds := groupIntoRounds(messages, 2)
+ if len(rounds) != 0 {
+ t.Errorf("expected 0 rounds, got %d", len(rounds))
+ }
+}
+
+func TestPartitionMessages_ShortConversation(t *testing.T) {
+ messages := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ result := partitionMessages(messages, 100000, 0)
+ if result.frozenEnd != 2 {
+ t.Errorf("frozenEnd = %d, want 2", result.frozenEnd)
+ }
+ if result.compressEnd != 2 {
+ t.Errorf("compressEnd = %d, want 2", result.compressEnd)
+ }
+}
+
+func TestPartitionMessages_EverythingFits(t *testing.T) {
+ messages := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ msg("assistant", "short reply"),
+ msg("tool", "ok"),
+ }
+ result := partitionMessages(messages, 100000, 0)
+ if result.activeCount != 0 {
+ t.Errorf("activeCount = %d, want 0 (everything fits)", result.activeCount)
+ }
+ if result.compressEnd != len(messages) {
+ t.Errorf("compressEnd = %d, want %d", result.compressEnd, len(messages))
+ }
+}
+
+func TestStripMarkdownFences(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "no fences",
+ input: `{"key": "value"}`,
+ want: `{"key": "value"}`,
+ },
+ {
+ name: "json fence",
+ input: "```json\n{\"key\": \"value\"}\n```",
+ want: `{"key": "value"}`,
+ },
+ {
+ name: "plain fence",
+ input: "```\ncontent\n```",
+ want: "content",
+ },
+ {
+ name: "fence with surrounding whitespace",
+ input: " ```json\n{}\n``` ",
+ want: "{}",
+ },
+ {
+ name: "empty after strip",
+ input: "```json\n```",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := StripMarkdownFences(tt.input)
+ if got != tt.want {
+ t.Errorf("StripMarkdownFences(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBuildMessageXML(t *testing.T) {
+ messages := []llm.Message{
+ msg("user", "hello"),
+ msg("assistant", "world"),
+ }
+ got := buildMessageXML(messages)
+ if !strings.Contains(got, ``) {
+ t.Errorf("missing user message tag: %s", got)
+ }
+ if !strings.Contains(got, ``) {
+ t.Errorf("missing assistant message tag: %s", got)
+ }
+ if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
+ t.Errorf("missing content: %s", got)
+ }
+}
+
+func TestCopyMessages(t *testing.T) {
+ orig := []llm.Message{msg("user", "a"), msg("assistant", "b")}
+ cp := copyMessages(orig)
+ if len(cp) != 2 {
+ t.Fatalf("len = %d, want 2", len(cp))
+ }
+ cp[0] = msg("system", "mutated")
+ if orig[0].Role == "system" {
+ t.Error("copyMessages should return independent slice")
+ }
+}
diff --git a/internal/suggestdiff/diff_test.go b/internal/suggestdiff/diff_test.go
new file mode 100644
index 0000000..1cb0bc7
--- /dev/null
+++ b/internal/suggestdiff/diff_test.go
@@ -0,0 +1,127 @@
+package suggestdiff
+
+import (
+ "testing"
+)
+
+func TestComputeLineDiff(t *testing.T) {
+ tests := []struct {
+ name string
+ old []string
+ new []string
+ wantLen int
+ wantAdds int
+ wantDels int
+ }{
+ {
+ name: "both empty",
+ old: nil,
+ new: nil,
+ wantLen: 0,
+ wantAdds: 0,
+ wantDels: 0,
+ },
+ {
+ name: "identical single line",
+ old: []string{"hello"},
+ new: []string{"hello"},
+ wantLen: 1,
+ wantAdds: 0,
+ wantDels: 0,
+ },
+ {
+ name: "add lines to empty",
+ old: []string{},
+ new: []string{"a", "b"},
+ wantLen: 2,
+ wantAdds: 2,
+ wantDels: 0,
+ },
+ {
+ name: "delete all lines",
+ old: []string{"a", "b"},
+ new: []string{},
+ wantLen: 2,
+ wantAdds: 0,
+ wantDels: 2,
+ },
+ {
+ name: "replace single line",
+ old: []string{"old"},
+ new: []string{"new"},
+ wantLen: 2,
+ wantAdds: 1,
+ wantDels: 1,
+ },
+ {
+ name: "insert in middle",
+ old: []string{"a", "c"},
+ new: []string{"a", "b", "c"},
+ wantLen: 3,
+ wantAdds: 1,
+ wantDels: 0,
+ },
+ {
+ name: "delete from middle",
+ old: []string{"a", "b", "c"},
+ new: []string{"a", "c"},
+ wantLen: 3,
+ wantAdds: 0,
+ wantDels: 1,
+ },
+ {
+ name: "case insensitive match with whitespace",
+ old: []string{" Hello "},
+ new: []string{"hello"},
+ wantLen: 1,
+ wantAdds: 0,
+ wantDels: 0,
+ },
+ {
+ name: "multi-line edit",
+ old: []string{"func main() {", " fmt.Println(\"old\")", "}"},
+ new: []string{"func main() {", " fmt.Println(\"new\")", " return", "}"},
+ wantLen: 5,
+ wantAdds: 2,
+ wantDels: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ComputeLineDiff(tt.old, tt.new)
+ if len(got) != tt.wantLen {
+ t.Errorf("len = %d, want %d; diff = %v", len(got), tt.wantLen, got)
+ }
+ var adds, dels int
+ for _, l := range got {
+ switch l.Type {
+ case DiffAdded:
+ adds++
+ case DiffDeleted:
+ dels++
+ }
+ }
+ if adds != tt.wantAdds {
+ t.Errorf("adds = %d, want %d", adds, tt.wantAdds)
+ }
+ if dels != tt.wantDels {
+ t.Errorf("dels = %d, want %d", dels, tt.wantDels)
+ }
+ })
+ }
+}
+
+func TestComputeLineDiff_ContextContent(t *testing.T) {
+ old := []string{"a", "b", "c"}
+ new := []string{"a", "x", "c"}
+ got := ComputeLineDiff(old, new)
+
+ if got[0].Type != DiffContext || got[0].Content != "a" {
+ t.Errorf("first line should be context 'a', got %+v", got[0])
+ }
+ last := got[len(got)-1]
+ if last.Type != DiffContext || last.Content != "c" {
+ t.Errorf("last line should be context 'c', got %+v", last)
+ }
+}
diff --git a/internal/tool/comment_collector_test.go b/internal/tool/comment_collector_test.go
new file mode 100644
index 0000000..e2628e8
--- /dev/null
+++ b/internal/tool/comment_collector_test.go
@@ -0,0 +1,152 @@
+package tool
+
+import (
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+func cm(path, content string) model.LlmComment {
+ return model.LlmComment{Path: path, Content: content}
+}
+
+func TestCommentCollector_AddAndComments(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "issue 1"))
+ c.Add(cm("b.go", "issue 2"))
+
+ got := c.Comments()
+ if len(got) != 2 {
+ t.Fatalf("expected 2 comments, got %d", len(got))
+ }
+ if got[0].Path != "a.go" || got[1].Path != "b.go" {
+ t.Errorf("unexpected paths: %v", got)
+ }
+}
+
+func TestCommentCollector_CommentsReturnsDefensiveCopy(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "x"))
+ got := c.Comments()
+ got[0].Content = "mutated"
+ if c.Comments()[0].Content == "mutated" {
+ t.Error("Comments() should return a copy")
+ }
+}
+
+func TestCommentCollector_CommentsForPath(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "1"))
+ c.Add(cm("b.go", "2"))
+ c.Add(cm("a.go", "3"))
+
+ got := c.CommentsForPath("a.go")
+ if len(got) != 2 {
+ t.Fatalf("expected 2 comments for a.go, got %d", len(got))
+ }
+ if got[0].Content != "1" || got[1].Content != "3" {
+ t.Errorf("unexpected: %v", got)
+ }
+
+ got = c.CommentsForPath("nonexist.go")
+ if len(got) != 0 {
+ t.Errorf("expected 0 for nonexistent path, got %d", len(got))
+ }
+}
+
+func TestCommentCollector_SnapshotAndSince(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "old"))
+ snap := c.Snapshot()
+ if snap != 1 {
+ t.Fatalf("snapshot = %d, want 1", snap)
+ }
+
+ c.Add(cm("b.go", "new1"))
+ c.Add(cm("c.go", "new2"))
+
+ since := c.Since(snap)
+ if len(since) != 2 {
+ t.Fatalf("Since(%d) len = %d, want 2", snap, len(since))
+ }
+ if since[0].Path != "b.go" || since[1].Path != "c.go" {
+ t.Errorf("unexpected: %v", since)
+ }
+}
+
+func TestCommentCollector_SinceEdgeCases(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "x"))
+
+ if got := c.Since(-1); len(got) != 1 {
+ t.Errorf("Since(-1) should clamp to 0, got len %d", len(got))
+ }
+ if got := c.Since(100); got != nil {
+ t.Errorf("Since(100) should return nil, got %v", got)
+ }
+}
+
+func TestCommentCollector_ReplaceSince(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "keep"))
+ snap := c.Snapshot()
+ c.Add(cm("b.go", "raw1"))
+ c.Add(cm("c.go", "raw2"))
+
+ c.ReplaceSince(snap, []model.LlmComment{cm("merged.go", "deduped")})
+
+ got := c.Comments()
+ if len(got) != 2 {
+ t.Fatalf("expected 2 comments after replace, got %d", len(got))
+ }
+ if got[0].Path != "a.go" {
+ t.Errorf("first comment should be kept, got %v", got[0])
+ }
+ if got[1].Path != "merged.go" {
+ t.Errorf("second comment should be replacement, got %v", got[1])
+ }
+}
+
+func TestCommentCollector_ReplaceSinceOutOfBounds(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "x"))
+ c.ReplaceSince(100, []model.LlmComment{cm("z.go", "nope")})
+ if len(c.Comments()) != 1 {
+ t.Error("ReplaceSince beyond len should be no-op")
+ }
+}
+
+func TestCommentCollector_RemoveByPathAndIndices(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "a0"))
+ c.Add(cm("b.go", "b0"))
+ c.Add(cm("a.go", "a1"))
+ c.Add(cm("a.go", "a2"))
+ c.Add(cm("b.go", "b1"))
+
+ c.RemoveByPathAndIndices("a.go", map[int]struct{}{0: {}, 2: {}})
+
+ got := c.Comments()
+ if len(got) != 3 {
+ t.Fatalf("expected 3 comments, got %d: %v", len(got), got)
+ }
+ paths := make([]string, len(got))
+ for i, g := range got {
+ paths[i] = g.Path + ":" + g.Content
+ }
+ want := []string{"b.go:b0", "a.go:a1", "b.go:b1"}
+ for i, w := range want {
+ if paths[i] != w {
+ t.Errorf("index %d: got %q, want %q", i, paths[i], w)
+ }
+ }
+}
+
+func TestCommentCollector_RemoveByPathAndIndices_NoMatch(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "x"))
+ c.RemoveByPathAndIndices("b.go", map[int]struct{}{0: {}})
+ if len(c.Comments()) != 1 {
+ t.Error("remove from non-matching path should be no-op")
+ }
+}
diff --git a/internal/tool/definitions_test.go b/internal/tool/definitions_test.go
new file mode 100644
index 0000000..a1f1af3
--- /dev/null
+++ b/internal/tool/definitions_test.go
@@ -0,0 +1,109 @@
+package tool
+
+import (
+ "context"
+ "testing"
+)
+
+func TestOfName(t *testing.T) {
+ tests := []struct {
+ name string
+ want Tool
+ }{
+ {"code_comment", CodeComment},
+ {"file_read", FileRead},
+ {"file_find", FileFind},
+ {"file_read_diff", FileReadDiff},
+ {"code_search", CodeSearch},
+ {"task_done", TaskDone},
+ {"nonexistent", Unknown},
+ {"", Unknown},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := OfName(tt.name)
+ if got != tt.want {
+ t.Errorf("OfName(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTool_Name(t *testing.T) {
+ if CodeComment.Name() != "code_comment" {
+ t.Errorf("CodeComment.Name() = %q", CodeComment.Name())
+ }
+}
+
+func TestTool_IsKnown(t *testing.T) {
+ if !CodeComment.IsKnown() {
+ t.Error("CodeComment should be known")
+ }
+ if Unknown.IsKnown() {
+ t.Error("Unknown should not be known")
+ }
+}
+
+func TestRegistry_RegisterAndGet(t *testing.T) {
+ reg := NewRegistry()
+ stub := NewStub(CodeComment)
+ reg.Register(stub)
+
+ got, ok := reg.Get("code_comment")
+ if !ok {
+ t.Fatal("expected to find code_comment")
+ }
+ if got.Tool() != CodeComment {
+ t.Errorf("got tool %v, want CodeComment", got.Tool())
+ }
+
+ _, ok = reg.Get("nonexistent")
+ if ok {
+ t.Error("should not find nonexistent tool")
+ }
+}
+
+func TestRegistry_Freeze_PanicsOnRegister(t *testing.T) {
+ reg := NewRegistry()
+ reg.Freeze()
+
+ defer func() {
+ if r := recover(); r == nil {
+ t.Error("expected panic on Register after Freeze")
+ }
+ }()
+ reg.Register(NewStub(FileRead))
+}
+
+func TestRegistry_GetAfterFreeze(t *testing.T) {
+ reg := NewRegistry()
+ reg.Register(NewStub(FileRead))
+ reg.Freeze()
+
+ _, ok := reg.Get("file_read")
+ if !ok {
+ t.Error("should still find tools after Freeze")
+ }
+}
+
+type dummyProvider struct{}
+
+func (d *dummyProvider) Tool() Tool { return CodeSearch }
+func (d *dummyProvider) Execute(_ context.Context, _ map[string]any) (string, error) {
+ return "result", nil
+}
+
+func TestRegistry_ProviderInterface(t *testing.T) {
+ reg := NewRegistry()
+ reg.Register(&dummyProvider{})
+ reg.Freeze()
+
+ p, ok := reg.Get("code_search")
+ if !ok {
+ t.Fatal("code_search not found")
+ }
+ result, err := p.Execute(context.Background(), nil)
+ if err != nil || result != "result" {
+ t.Errorf("Execute() = %q, %v", result, err)
+ }
+}
diff --git a/internal/tool/file_read_diff_test.go b/internal/tool/file_read_diff_test.go
new file mode 100644
index 0000000..dbd37c2
--- /dev/null
+++ b/internal/tool/file_read_diff_test.go
@@ -0,0 +1,106 @@
+package tool
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestNewDiffMap_DefensiveCopy(t *testing.T) {
+ orig := map[string]string{"a.go": "diff a"}
+ dm := NewDiffMap(orig)
+ orig["a.go"] = "mutated"
+ if v, _ := dm.Get("a.go"); v != "diff a" {
+ t.Error("NewDiffMap should make a defensive copy")
+ }
+}
+
+func TestDiffMap_Get(t *testing.T) {
+ dm := NewDiffMap(map[string]string{"x.go": "content"})
+
+ v, ok := dm.Get("x.go")
+ if !ok || v != "content" {
+ t.Errorf("Get(x.go) = %q, %v; want 'content', true", v, ok)
+ }
+
+ _, ok = dm.Get("missing.go")
+ if ok {
+ t.Error("Get(missing.go) should return false")
+ }
+}
+
+func TestFileReadDiffProvider_Execute(t *testing.T) {
+ dm := NewDiffMap(map[string]string{
+ "a.go": "@@ -1 +1 @@\n-old\n+new",
+ "b.go": "@@ -5 +5 @@\n-foo\n+bar",
+ })
+ p := NewFileReadDiff(dm)
+
+ tests := []struct {
+ name string
+ args map[string]any
+ wantSub string
+ wantErr string
+ }{
+ {
+ name: "single existing path",
+ args: map[string]any{"path_array": []any{"a.go"}},
+ wantSub: "==== FILE: a.go ====",
+ },
+ {
+ name: "multiple paths",
+ args: map[string]any{"path_array": []any{"a.go", "b.go"}},
+ wantSub: "==== FILE: b.go ====",
+ },
+ {
+ name: "missing path",
+ args: map[string]any{"path_array": []any{"missing.go"}},
+ wantErr: "Error: diff not found",
+ },
+ {
+ name: "empty path_array",
+ args: map[string]any{"path_array": []any{}},
+ wantErr: "Error: no files found",
+ },
+ {
+ name: "nil path_array",
+ args: map[string]any{},
+ wantErr: "Error: no files found",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := p.Execute(context.Background(), tt.args)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if tt.wantErr != "" {
+ if !strings.Contains(got, tt.wantErr) {
+ t.Errorf("got %q, want containing %q", got, tt.wantErr)
+ }
+ return
+ }
+ if !strings.Contains(got, tt.wantSub) {
+ t.Errorf("got %q, want containing %q", got, tt.wantSub)
+ }
+ })
+ }
+}
+
+func TestFileReadDiffProvider_SetDiffMap(t *testing.T) {
+ p := NewFileReadDiff(NewDiffMap(map[string]string{"old.go": "v1"}))
+ p.SetDiffMap(NewDiffMap(map[string]string{"new.go": "v2"}))
+
+ got, _ := p.Execute(context.Background(), map[string]any{"path_array": []any{"new.go"}})
+ if !strings.Contains(got, "new.go") {
+ t.Errorf("SetDiffMap not applied: %q", got)
+ }
+}
+
+func TestFileReadDiffProvider_Tool(t *testing.T) {
+ p := NewFileReadDiff(NewDiffMap(nil))
+ if p.Tool() != FileReadDiff {
+ t.Errorf("Tool() = %v, want FileReadDiff", p.Tool())
+ }
+}
diff --git a/internal/tool/filereader_test.go b/internal/tool/filereader_test.go
new file mode 100644
index 0000000..86c2579
--- /dev/null
+++ b/internal/tool/filereader_test.go
@@ -0,0 +1,139 @@
+package tool
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseReviewMode(t *testing.T) {
+ tests := []struct {
+ name string
+ from, to, comm string
+ want ReviewMode
+ }{
+ {"workspace default", "", "", "", ModeWorkspace},
+ {"range mode", "HEAD~1", "HEAD", "", ModeRange},
+ {"commit mode", "", "", "abc123", ModeCommit},
+ {"commit takes precedence", "a", "b", "c", ModeCommit},
+ {"from only is workspace", "HEAD~1", "", "", ModeWorkspace},
+ {"to only is workspace", "", "HEAD", "", ModeWorkspace},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ParseReviewMode(tt.from, tt.to, tt.comm)
+ if got != tt.want {
+ t.Errorf("ParseReviewMode(%q,%q,%q) = %v, want %v", tt.from, tt.to, tt.comm, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestReviewMode_RefValue(t *testing.T) {
+ tests := []struct {
+ name string
+ mode ReviewMode
+ toRef string
+ commit string
+ wantRef string
+ wantOK bool
+ }{
+ {"workspace returns empty", ModeWorkspace, "HEAD", "abc", "", false},
+ {"range returns toRef", ModeRange, "HEAD", "", "HEAD", true},
+ {"commit returns commit", ModeCommit, "HEAD", "abc123", "abc123", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ref, ok := tt.mode.RefValue(tt.toRef, tt.commit)
+ if ref != tt.wantRef || ok != tt.wantOK {
+ t.Errorf("RefValue(%q,%q) = (%q,%v), want (%q,%v)", tt.toRef, tt.commit, ref, ok, tt.wantRef, tt.wantOK)
+ }
+ })
+ }
+}
+
+func TestScanLines(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ startLine int
+ maxLines int
+ wantLines []string
+ wantTotal int
+ }{
+ {
+ name: "full file",
+ input: "line1\nline2\nline3\n",
+ startLine: 1,
+ maxLines: 100,
+ wantLines: []string{"line1", "line2", "line3", ""},
+ wantTotal: 4,
+ },
+ {
+ name: "no trailing newline",
+ input: "line1\nline2",
+ startLine: 1,
+ maxLines: 100,
+ wantLines: []string{"line1", "line2"},
+ wantTotal: 2,
+ },
+ {
+ name: "start from line 2",
+ input: "a\nb\nc\n",
+ startLine: 2,
+ maxLines: 100,
+ wantLines: []string{"b", "c", ""},
+ wantTotal: 4,
+ },
+ {
+ name: "limit lines",
+ input: "a\nb\nc\nd\n",
+ startLine: 1,
+ maxLines: 2,
+ wantLines: []string{"a", "b"},
+ wantTotal: 5,
+ },
+ {
+ name: "start beyond end",
+ input: "a\nb\n",
+ startLine: 10,
+ maxLines: 100,
+ wantLines: nil,
+ wantTotal: 3,
+ },
+ {
+ name: "empty input",
+ input: "",
+ startLine: 1,
+ maxLines: 100,
+ wantLines: nil,
+ wantTotal: 0,
+ },
+ {
+ name: "crlf line endings",
+ input: "line1\r\nline2\r\n",
+ startLine: 1,
+ maxLines: 100,
+ wantLines: []string{"line1", "line2", ""},
+ wantTotal: 3,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ lines, total, err := scanLines(strings.NewReader(tt.input), tt.startLine, tt.maxLines)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if total != tt.wantTotal {
+ t.Errorf("total = %d, want %d", total, tt.wantTotal)
+ }
+ if len(lines) != len(tt.wantLines) {
+ t.Fatalf("lines count = %d, want %d; lines=%v", len(lines), len(tt.wantLines), lines)
+ }
+ for i, l := range lines {
+ if l != tt.wantLines[i] {
+ t.Errorf("line[%d] = %q, want %q", i, l, tt.wantLines[i])
+ }
+ }
+ })
+ }
+}
diff --git a/internal/viewer/server_test.go b/internal/viewer/server_test.go
new file mode 100644
index 0000000..2c3a833
--- /dev/null
+++ b/internal/viewer/server_test.go
@@ -0,0 +1,73 @@
+package viewer
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTruncateText(t *testing.T) {
+ tests := []struct {
+ name string
+ n int
+ s string
+ want string
+ }{
+ {"short string unchanged", 10, "hello", "hello"},
+ {"exact length unchanged", 5, "hello", "hello"},
+ {"truncated with ellipsis", 3, "hello", "hel…"},
+ {"empty string", 5, "", ""},
+ {"n=0 always truncates non-empty", 0, "hi", "…"},
+ {"unicode shorter than n bytes", 20, "你好世界", "你好世界"},
+ {"unicode truncated at byte boundary", 6, "你好世界", "你好…"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := truncateText(tt.n, tt.s)
+ if got != tt.want {
+ t.Errorf("truncateText(%d, %q) = %q, want %q", tt.n, tt.s, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFormatDuration(t *testing.T) {
+ tests := []struct {
+ name string
+ seconds float64
+ want string
+ }{
+ {"zero", 0, "0.0s"},
+ {"sub-second", 0.5, "0.5s"},
+ {"seconds only", 45.3, "45.3s"},
+ {"exactly one minute", 60, "1m0s"},
+ {"minutes and seconds", 125, "2m5s"},
+ {"large value", 3661, "61m1s"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatDuration(tt.seconds)
+ if got != tt.want {
+ t.Errorf("formatDuration(%v) = %q, want %q", tt.seconds, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFormatTime(t *testing.T) {
+ cst := time.FixedZone("CST", 8*60*60)
+ input := time.Date(2025, 3, 15, 14, 30, 0, 0, cst)
+ got := formatTime(input)
+ want := "2025-03-15 14:30"
+ if got != want {
+ t.Errorf("formatTime() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatTime_UTC(t *testing.T) {
+ input := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
+ got := formatTime(input)
+ want := "2025-01-01 08:00"
+ if got != want {
+ t.Errorf("formatTime(UTC midnight) = %q, want %q (should be +8h)", got, want)
+ }
+}
From 904ecd3ae1121fb5098b510e175efac1293e212e Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 23:09:11 +0800
Subject: [PATCH 04/87] test: expand unit test coverage for agent, llm,
llmloop, and tool packages
Add integration-style tests with fake LLM clients for agent dispatch and
llmloop runner, plus new unit test files for gitcmd, session/history,
tool/code_comment, tool/filereader_read, and viewer/store packages.
---
internal/agent/agent_test.go | 265 ++++++++++++++++++++++++++
internal/gitcmd/runner_test.go | 151 +++++++++++++++
internal/llm/client_test.go | 87 +++++++++
internal/llmloop/loop_test.go | 227 ++++++++++++++++++++++
internal/llmloop/pool_test.go | 99 ++++++++++
internal/session/history_test.go | 214 +++++++++++++++++++++
internal/tool/code_comment_test.go | 190 ++++++++++++++++++
internal/tool/filereader_read_test.go | 130 +++++++++++++
internal/viewer/store_test.go | 241 +++++++++++++++++++++++
9 files changed, 1604 insertions(+)
create mode 100644 internal/gitcmd/runner_test.go
create mode 100644 internal/llmloop/pool_test.go
create mode 100644 internal/session/history_test.go
create mode 100644 internal/tool/code_comment_test.go
create mode 100644 internal/tool/filereader_read_test.go
create mode 100644 internal/viewer/store_test.go
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index a3b65b9..99f8280 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -1,15 +1,88 @@
package agent
import (
+ "context"
"encoding/json"
"strings"
"testing"
+ "github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/tool"
)
+type fakeAgentClient struct {
+ responses []*llm.ChatResponse
+ calls int
+}
+
+func (f *fakeAgentClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
+ if f.calls >= len(f.responses) {
+ content := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
+ Model: "fake",
+ }, nil
+ }
+ resp := f.responses[f.calls]
+ f.calls++
+ return resp, nil
+}
+
+func agentTaskDoneResponse() *llm.ChatResponse {
+ content := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ ToolCalls: []llm.ToolCall{{
+ ID: "call_done",
+ Type: "function",
+ Function: llm.FunctionCall{
+ Name: "task_done",
+ Arguments: `{}`,
+ },
+ }},
+ },
+ }},
+ Model: "fake",
+ Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
+ }
+}
+
+func codeCommentResponse(path string) *llm.ChatResponse {
+ content := ""
+ args := map[string]any{
+ "path": path,
+ "comments": []any{
+ map[string]any{
+ "content": "potential null pointer",
+ "existing_code": "foo := bar.Baz()",
+ },
+ },
+ }
+ argsJSON, _ := json.Marshal(args)
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ ToolCalls: []llm.ToolCall{{
+ ID: "call_comment",
+ Type: "function",
+ Function: llm.FunctionCall{
+ Name: "code_comment",
+ Arguments: string(argsJSON),
+ },
+ }},
+ },
+ }},
+ Model: "fake",
+ Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 20},
+ }
+}
+
func TestBuildFilterCommentsJSON(t *testing.T) {
tests := []struct {
name string
@@ -302,3 +375,195 @@ func TestBuildToolDefs(t *testing.T) {
}
})
}
+
+func TestFilterLargeDiffs(t *testing.T) {
+ a := New(Args{
+ Template: template.Template{MaxTokens: 100},
+ })
+
+ diffs := []model.Diff{
+ {NewPath: "small.go", Diff: "short diff"},
+ {NewPath: "large.go", Diff: strings.Repeat("word ", 500)},
+ }
+
+ kept := a.filterLargeDiffs(diffs)
+ if len(kept) != 1 {
+ t.Fatalf("expected 1 kept diff, got %d", len(kept))
+ }
+ if kept[0].NewPath != "small.go" {
+ t.Errorf("kept wrong file: %s", kept[0].NewPath)
+ }
+}
+
+func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) {
+ a := New(Args{
+ Template: template.Template{MaxTokens: 0},
+ })
+
+ diffs := []model.Diff{{NewPath: "a.go", Diff: "some diff"}}
+ kept := a.filterLargeDiffs(diffs)
+ if len(kept) != 1 {
+ t.Errorf("expected all kept when MaxTokens=0, got %d", len(kept))
+ }
+}
+
+func TestCountReviewable(t *testing.T) {
+ a := New(Args{})
+ diffs := []model.Diff{
+ {NewPath: "main.go", Insertions: 10, Deletions: 2},
+ {NewPath: "deleted.go", IsDeleted: true, Deletions: 20},
+ {NewPath: "binary.bin", IsBinary: true},
+ {NewPath: "helper.go", Insertions: 5},
+ }
+
+ count := a.countReviewable(diffs)
+ if count != 2 {
+ t.Errorf("countReviewable = %d, want 2", count)
+ }
+}
+
+func TestBuildChangeFilesExcept(t *testing.T) {
+ a := New(Args{})
+ a.diffs = []model.Diff{
+ {NewPath: "main.go", OldPath: "main.go"},
+ {NewPath: "helper.go", OldPath: "helper.go", IsNew: true},
+ {NewPath: "removed.go", OldPath: "removed.go", IsDeleted: true},
+ {NewPath: "renamed.go", OldPath: "old_name.go"},
+ {NewPath: "bin.dat", OldPath: "bin.dat", IsBinary: true},
+ }
+
+ got := a.buildChangeFilesExcept("main.go")
+ if strings.Contains(got, "main.go") {
+ t.Error("excluded file should not appear")
+ }
+ if !strings.Contains(got, "ADDED") {
+ t.Error("expected ADDED status for new file")
+ }
+ if !strings.Contains(got, "DELETED") {
+ t.Error("expected DELETED status")
+ }
+ if !strings.Contains(got, "RENAMED") {
+ t.Error("expected RENAMED status")
+ }
+ if strings.Contains(got, "bin.dat") {
+ t.Error("binary files should be skipped")
+ }
+}
+
+func TestDispatchSubtasks_WithFakeLLM(t *testing.T) {
+ client := &fakeAgentClient{responses: []*llm.ChatResponse{
+ codeCommentResponse("main.go"),
+ agentTaskDoneResponse(),
+ }}
+
+ collector := tool.NewCommentCollector()
+ reg := tool.NewRegistry()
+ reg.Register(&tool.CodeCommentProvider{Collector: collector})
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "fake",
+ CommentCollector: collector,
+ Tools: reg,
+ Template: template.Template{
+ MaxTokens: 100000,
+ MaxToolRequestTimes: 10,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Review {{diff}} for {{current_file_path}}"},
+ },
+ },
+ },
+ MainToolDefs: []llm.ToolDef{
+ {Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
+ {Type: "function", Function: llm.FunctionDef{Name: "code_comment", Description: "comment"}},
+ },
+ })
+
+ a.diffs = []model.Diff{
+ {NewPath: "main.go", OldPath: "main.go", Diff: "+new line", Insertions: 1},
+ }
+ a.currentDate = "2025-06-26 10:00"
+
+ comments, err := a.dispatchSubtasks(context.Background())
+ if err != nil {
+ t.Fatalf("dispatchSubtasks: %v", err)
+ }
+ if len(comments) != 1 {
+ t.Fatalf("expected 1 comment, got %d", len(comments))
+ }
+ if comments[0].Path != "main.go" {
+ t.Errorf("Path = %q, want main.go", comments[0].Path)
+ }
+ if !strings.Contains(comments[0].Content, "null pointer") {
+ t.Errorf("Content = %q", comments[0].Content)
+ }
+}
+
+func TestDispatchSubtasks_AllDeleted(t *testing.T) {
+ client := &fakeAgentClient{}
+ a := New(Args{
+ LLMClient: client,
+ Model: "fake",
+ Template: template.Template{
+ MaxTokens: 100000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Review {{diff}}"},
+ },
+ },
+ },
+ })
+
+ a.diffs = []model.Diff{
+ {NewPath: "removed.go", IsDeleted: true},
+ }
+ a.currentDate = "2025-06-26 10:00"
+
+ comments, err := a.dispatchSubtasks(context.Background())
+ if err != nil {
+ t.Fatalf("dispatchSubtasks: %v", err)
+ }
+ if len(comments) != 0 {
+ t.Errorf("expected 0 comments for deleted file, got %d", len(comments))
+ }
+ if client.calls != 0 {
+ t.Errorf("expected 0 LLM calls, got %d", client.calls)
+ }
+}
+
+func TestAgent_TokenAccumulation(t *testing.T) {
+ client := &fakeAgentClient{responses: []*llm.ChatResponse{
+ agentTaskDoneResponse(),
+ }}
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "fake",
+ Template: template.Template{
+ MaxTokens: 100000,
+ MaxToolRequestTimes: 10,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Review {{diff}}"},
+ },
+ },
+ },
+ })
+ a.diffs = []model.Diff{
+ {NewPath: "a.go", Diff: "+x", Insertions: 1},
+ }
+ a.currentDate = "2025-06-26 10:00"
+
+ _, err := a.dispatchSubtasks(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.TotalInputTokens() != 10 {
+ t.Errorf("TotalInputTokens = %d, want 10", a.TotalInputTokens())
+ }
+ if a.TotalOutputTokens() != 5 {
+ t.Errorf("TotalOutputTokens = %d, want 5", a.TotalOutputTokens())
+ }
+}
diff --git a/internal/gitcmd/runner_test.go b/internal/gitcmd/runner_test.go
new file mode 100644
index 0000000..f803ebe
--- /dev/null
+++ b/internal/gitcmd/runner_test.go
@@ -0,0 +1,151 @@
+package gitcmd
+
+import (
+ "context"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func initRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test",
+ "GIT_AUTHOR_EMAIL=test@test.com",
+ "GIT_COMMITTER_NAME=test",
+ "GIT_COMMITTER_EMAIL=test@test.com",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run("init")
+ run("config", "user.email", "test@test.com")
+ run("config", "user.name", "test")
+ if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run("add", "hello.txt")
+ run("commit", "-m", "init")
+ return dir
+}
+
+func TestRunner_New(t *testing.T) {
+ r := New(0)
+ if r == nil {
+ t.Fatal("New(0) returned nil")
+ }
+ if cap(r.sem) != defaultMaxConcurrent {
+ t.Errorf("default capacity = %d, want %d", cap(r.sem), defaultMaxConcurrent)
+ }
+
+ r2 := New(4)
+ if cap(r2.sem) != 4 {
+ t.Errorf("capacity = %d, want 4", cap(r2.sem))
+ }
+}
+
+func TestRunner_Run(t *testing.T) {
+ dir := initRepo(t)
+ r := New(2)
+
+ out, err := r.Run(context.Background(), dir, "log", "--oneline")
+ if err != nil {
+ t.Fatalf("Run error: %v", err)
+ }
+ if !strings.Contains(out, "init") {
+ t.Errorf("expected 'init' in output: %q", out)
+ }
+}
+
+func TestRunner_Run_InvalidCommand(t *testing.T) {
+ dir := initRepo(t)
+ r := New(2)
+
+ _, err := r.Run(context.Background(), dir, "nonexistent-subcommand")
+ if err == nil {
+ t.Error("expected error for invalid git subcommand")
+ }
+}
+
+func TestRunner_Output(t *testing.T) {
+ dir := initRepo(t)
+ r := New(2)
+
+ out, err := r.Output(context.Background(), dir, "rev-parse", "HEAD")
+ if err != nil {
+ t.Fatalf("Output error: %v", err)
+ }
+ hash := strings.TrimSpace(string(out))
+ if len(hash) != 40 {
+ t.Errorf("expected 40-char hash, got %q", hash)
+ }
+}
+
+func TestRunner_RunSplit(t *testing.T) {
+ dir := initRepo(t)
+ r := New(2)
+
+ stdout, stderr, err := r.RunSplit(context.Background(), dir, "status", "--short")
+ if err != nil {
+ t.Fatalf("RunSplit error: %v", err)
+ }
+ _ = stderr
+ if strings.Contains(stdout, "??") {
+ t.Errorf("unexpected untracked files in clean repo: %q", stdout)
+ }
+}
+
+func TestRunner_Stream(t *testing.T) {
+ dir := initRepo(t)
+ r := New(2)
+
+ var content string
+ err := r.Stream(context.Background(), dir, func(stdout io.Reader) error {
+ data, err := io.ReadAll(stdout)
+ if err != nil {
+ return err
+ }
+ content = string(data)
+ return nil
+ }, "show", "HEAD:hello.txt")
+ if err != nil {
+ t.Fatalf("Stream error: %v", err)
+ }
+ if content != "hello\n" {
+ t.Errorf("Stream content = %q, want %q", content, "hello\n")
+ }
+}
+
+func TestRunner_ContextCancelled(t *testing.T) {
+ r := New(1)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := r.Run(ctx, ".", "status")
+ if err == nil {
+ t.Error("expected error for cancelled context")
+ }
+}
+
+func TestRunner_AcquireTimeout(t *testing.T) {
+ r := New(1)
+ r.sem <- struct{}{}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ _, err := r.Run(ctx, ".", "status")
+ if err == nil {
+ t.Error("expected timeout error when semaphore full")
+ }
+}
diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go
index fc23feb..50db73c 100644
--- a/internal/llm/client_test.go
+++ b/internal/llm/client_test.go
@@ -482,3 +482,90 @@ func TestAnthropicClient_NoExtraHeadersWhenEmpty(t *testing.T) {
// Verify the SDK constant is accessible (compile-time check).
var _ anthropic.CacheControlEphemeralParam = anthropic.NewCacheControlEphemeralParam()
+
+func TestCountTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ want int
+ }{
+ {"empty", "", 0},
+ {"single word", "hello", 1},
+ {"sentence", "The quick brown fox jumps over the lazy dog.", 10},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := CountTokens(tt.text)
+ if tt.want == 0 && got != 0 {
+ t.Errorf("CountTokens(%q) = %d, want 0", tt.text, got)
+ }
+ if tt.want > 0 && got == 0 {
+ t.Errorf("CountTokens(%q) = 0, expected > 0", tt.text)
+ }
+ })
+ }
+}
+
+func TestCountTokensForModel(t *testing.T) {
+ text := "Hello, world! This is a test."
+ base := CountTokensForModel(text, "gpt-4")
+ o1 := CountTokensForModel(text, "o1-mini")
+
+ if base == 0 {
+ t.Error("cl100k_base should produce non-zero tokens")
+ }
+ if o1 == 0 {
+ t.Error("o200k_base should produce non-zero tokens")
+ }
+
+ if CountTokensForModel("", "gpt-4") != 0 {
+ t.Error("empty text should return 0")
+ }
+}
+
+func TestEncodingForModel(t *testing.T) {
+ tests := []struct {
+ model string
+ want string
+ }{
+ {"gpt-4", "cl100k_base"},
+ {"claude-3-opus", "cl100k_base"},
+ {"", "cl100k_base"},
+ {"o1-preview", "o200k_base"},
+ {"o3-mini", "o200k_base"},
+ {"o4-mini", "o200k_base"},
+ {"GPT-O1", "o200k_base"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ got := encodingForModel(tt.model)
+ if got != tt.want {
+ t.Errorf("encodingForModel(%q) = %q, want %q", tt.model, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStripThinkTags(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no tags", "hello world", "hello world"},
+ {"open only", "partial", "partial"},
+ {"close only", "partial", "partial"},
+ {"both tags", "reasoning hereanswer", "reasoning hereanswer"},
+ {"multiple tags", "abcd", "abcd"},
+ {"empty", "", ""},
+ {"tags only", "", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripThinkTags(tt.input)
+ if got != tt.want {
+ t.Errorf("stripThinkTags(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go
index 2688aa7..5b6d449 100644
--- a/internal/llmloop/loop_test.go
+++ b/internal/llmloop/loop_test.go
@@ -5,10 +5,237 @@ import (
"encoding/json"
"testing"
+ "github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/session"
"github.com/open-code-review/open-code-review/internal/tool"
)
+type fakeClient struct {
+ responses []*llm.ChatResponse
+ calls int
+}
+
+func (f *fakeClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
+ if f.calls >= len(f.responses) {
+ content := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
+ Model: "fake",
+ }, nil
+ }
+ resp := f.responses[f.calls]
+ f.calls++
+ return resp, nil
+}
+
+func taskDoneResponse() *llm.ChatResponse {
+ content := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ ToolCalls: []llm.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: llm.FunctionCall{
+ Name: "task_done",
+ Arguments: `{}`,
+ },
+ }},
+ },
+ }},
+ Model: "fake",
+ Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
+ }
+}
+
+func fileReadToolCallResponse(callID, args string) *llm.ChatResponse {
+ content := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ ToolCalls: []llm.ToolCall{{
+ ID: callID,
+ Type: "function",
+ Function: llm.FunctionCall{
+ Name: "file_read",
+ Arguments: args,
+ },
+ }},
+ },
+ }},
+ Model: "fake",
+ Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10},
+ }
+}
+
+type fakeFileReadProvider struct {
+ result string
+}
+
+func (f *fakeFileReadProvider) Tool() tool.Tool { return tool.FileRead }
+func (f *fakeFileReadProvider) Execute(_ context.Context, _ map[string]any) (string, error) {
+ return f.result, nil
+}
+
+func newTestDeps(client llm.LLMClient) Deps {
+ reg := tool.NewRegistry()
+ reg.Register(&fakeFileReadProvider{result: "package main\n"})
+ return Deps{
+ LLMClient: client,
+ Model: "fake",
+ Template: template.Template{MaxTokens: 100000, MaxToolRequestTimes: 10},
+ Tools: reg,
+ CommentCollector: tool.NewCommentCollector(),
+ Session: session.New("/tmp/test-repo", "main", "fake", session.SessionOptions{}),
+ }
+}
+
+func TestRunPerFile_TaskDoneImmediately(t *testing.T) {
+ client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}}
+ deps := newTestDeps(client)
+ runner := NewRunner(deps)
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "review this file")}
+ err := runner.RunPerFile(context.Background(), msgs, "main.go")
+ if err != nil {
+ t.Fatalf("RunPerFile: %v", err)
+ }
+ if client.calls != 1 {
+ t.Errorf("expected 1 LLM call, got %d", client.calls)
+ }
+ if runner.TotalInputTokens() != 10 {
+ t.Errorf("TotalInputTokens = %d, want 10", runner.TotalInputTokens())
+ }
+ if runner.TotalOutputTokens() != 5 {
+ t.Errorf("TotalOutputTokens = %d, want 5", runner.TotalOutputTokens())
+ }
+}
+
+func TestRunPerFile_ToolCallThenDone(t *testing.T) {
+ client := &fakeClient{responses: []*llm.ChatResponse{
+ fileReadToolCallResponse("call_1", `{"path":"main.go"}`),
+ taskDoneResponse(),
+ }}
+ deps := newTestDeps(client)
+ runner := NewRunner(deps)
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "review")}
+ err := runner.RunPerFile(context.Background(), msgs, "main.go")
+ if err != nil {
+ t.Fatalf("RunPerFile: %v", err)
+ }
+ if client.calls != 2 {
+ t.Errorf("expected 2 LLM calls, got %d", client.calls)
+ }
+
+ toolCalls := runner.ToolCalls()
+ if toolCalls["file_read"] != 1 {
+ t.Errorf("file_read calls = %d, want 1", toolCalls["file_read"])
+ }
+ if runner.TotalInputTokens() != 30 {
+ t.Errorf("TotalInputTokens = %d, want 30", runner.TotalInputTokens())
+ }
+}
+
+func TestRunPerFile_ContextCancelled(t *testing.T) {
+ client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}}
+ deps := newTestDeps(client)
+ runner := NewRunner(deps)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "review")}
+ err := runner.RunPerFile(ctx, msgs, "main.go")
+ if err == nil {
+ t.Error("expected error for cancelled context")
+ }
+}
+
+func TestRunPerFile_UnknownTool(t *testing.T) {
+ content := ""
+ unknownToolResp := &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ ToolCalls: []llm.ToolCall{{
+ ID: "call_x",
+ Type: "function",
+ Function: llm.FunctionCall{
+ Name: "nonexistent_tool",
+ Arguments: `{}`,
+ },
+ }},
+ },
+ }},
+ Model: "fake",
+ Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5},
+ }
+ client := &fakeClient{responses: []*llm.ChatResponse{unknownToolResp, taskDoneResponse()}}
+ deps := newTestDeps(client)
+ runner := NewRunner(deps)
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "review")}
+ err := runner.RunPerFile(context.Background(), msgs, "main.go")
+ if err != nil {
+ t.Fatalf("RunPerFile: %v", err)
+ }
+ if client.calls != 2 {
+ t.Errorf("expected 2 calls, got %d", client.calls)
+ }
+}
+
+func TestRunner_RecordWarning(t *testing.T) {
+ deps := newTestDeps(&fakeClient{})
+ runner := NewRunner(deps)
+
+ runner.RecordWarning("token_limit", "a.go", "approaching token limit")
+ runner.RecordWarning("parse_error", "b.go", "invalid JSON")
+
+ warnings := runner.Warnings()
+ if len(warnings) != 2 {
+ t.Fatalf("expected 2 warnings, got %d", len(warnings))
+ }
+ if warnings[0].Type != "token_limit" {
+ t.Errorf("Type = %q", warnings[0].Type)
+ }
+ if warnings[1].File != "b.go" {
+ t.Errorf("File = %q", warnings[1].File)
+ }
+}
+
+func TestRunner_RecordUsage(t *testing.T) {
+ deps := newTestDeps(&fakeClient{})
+ runner := NewRunner(deps)
+
+ runner.RecordUsage(&llm.UsageInfo{
+ PromptTokens: 100,
+ CompletionTokens: 50,
+ CacheReadTokens: 20,
+ CacheWriteTokens: 10,
+ })
+ runner.RecordUsage(nil)
+
+ if runner.TotalInputTokens() != 100 {
+ t.Errorf("input = %d", runner.TotalInputTokens())
+ }
+ if runner.TotalOutputTokens() != 50 {
+ t.Errorf("output = %d", runner.TotalOutputTokens())
+ }
+ if runner.TotalCacheReadTokens() != 20 {
+ t.Errorf("cache read = %d", runner.TotalCacheReadTokens())
+ }
+ if runner.TotalCacheWriteTokens() != 10 {
+ t.Errorf("cache write = %d", runner.TotalCacheWriteTokens())
+ }
+ if runner.TotalTokensUsed() != 150 {
+ t.Errorf("total = %d", runner.TotalTokensUsed())
+ }
+}
+
func TestExecuteToolCall_CodeCommentOverridesHallucinatedPath(t *testing.T) {
collector := tool.NewCommentCollector()
reg := tool.NewRegistry()
diff --git a/internal/llmloop/pool_test.go b/internal/llmloop/pool_test.go
new file mode 100644
index 0000000..1db6bbe
--- /dev/null
+++ b/internal/llmloop/pool_test.go
@@ -0,0 +1,99 @@
+package llmloop
+
+import (
+ "errors"
+ "sync/atomic"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+func TestNewCommentWorkerPool_Default(t *testing.T) {
+ p := NewCommentWorkerPool(0)
+ if cap(p.semaphore) != 8 {
+ t.Errorf("default capacity = %d, want 8", cap(p.semaphore))
+ }
+}
+
+func TestNewCommentWorkerPool_Custom(t *testing.T) {
+ p := NewCommentWorkerPool(4)
+ if cap(p.semaphore) != 4 {
+ t.Errorf("capacity = %d, want 4", cap(p.semaphore))
+ }
+}
+
+func TestCommentWorkerPool_SubmitAndAwait(t *testing.T) {
+ p := NewCommentWorkerPool(2)
+
+ p.Submit(func() ([]model.LlmComment, error) {
+ return []model.LlmComment{{Path: "a.go", Content: "issue 1"}}, nil
+ })
+ p.Submit(func() ([]model.LlmComment, error) {
+ return []model.LlmComment{{Path: "b.go", Content: "issue 2"}, {Path: "b.go", Content: "issue 3"}}, nil
+ })
+
+ results := p.Await()
+ if len(results) != 3 {
+ t.Fatalf("expected 3 results, got %d", len(results))
+ }
+
+ paths := map[string]bool{}
+ for _, r := range results {
+ paths[r.Path] = true
+ }
+ if !paths["a.go"] || !paths["b.go"] {
+ t.Errorf("unexpected paths: %v", results)
+ }
+}
+
+func TestCommentWorkerPool_ErrorDoesNotBlock(t *testing.T) {
+ p := NewCommentWorkerPool(2)
+
+ p.Submit(func() ([]model.LlmComment, error) {
+ return nil, errors.New("oops")
+ })
+ p.Submit(func() ([]model.LlmComment, error) {
+ return []model.LlmComment{{Path: "ok.go", Content: "fine"}}, nil
+ })
+
+ results := p.Await()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result after error, got %d", len(results))
+ }
+ if results[0].Path != "ok.go" {
+ t.Errorf("Path = %q", results[0].Path)
+ }
+}
+
+func TestCommentWorkerPool_Concurrency(t *testing.T) {
+ p := NewCommentWorkerPool(3)
+ var running atomic.Int32
+ var maxRunning atomic.Int32
+
+ for i := 0; i < 10; i++ {
+ p.Submit(func() ([]model.LlmComment, error) {
+ cur := running.Add(1)
+ for {
+ old := maxRunning.Load()
+ if cur <= old || maxRunning.CompareAndSwap(old, cur) {
+ break
+ }
+ }
+ running.Add(-1)
+ return nil, nil
+ })
+ }
+
+ p.Await()
+ if maxRunning.Load() > 3 {
+ t.Errorf("max concurrent = %d, expected <= 3", maxRunning.Load())
+ }
+}
+
+func TestCommentWorkerPool_AwaitEmpty(t *testing.T) {
+ p := NewCommentWorkerPool(2)
+ results := p.Await()
+ if results != nil {
+ t.Errorf("expected nil for no submissions, got %v", results)
+ }
+}
diff --git a/internal/session/history_test.go b/internal/session/history_test.go
new file mode 100644
index 0000000..4daf05a
--- /dev/null
+++ b/internal/session/history_test.go
@@ -0,0 +1,214 @@
+package session
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
+)
+
+func TestNew(t *testing.T) {
+ sh := New("/tmp/repo", "main", "gpt-4", SessionOptions{
+ ReviewMode: ReviewModeWorkspace,
+ DiffFrom: "a",
+ DiffTo: "b",
+ DiffCommit: "c",
+ })
+ if sh == nil {
+ t.Fatal("New returned nil")
+ }
+ if sh.SessionID == "" {
+ t.Error("SessionID should not be empty")
+ }
+ if sh.RepoDir != "/tmp/repo" {
+ t.Errorf("RepoDir = %q", sh.RepoDir)
+ }
+ if sh.GitBranch != "main" {
+ t.Errorf("GitBranch = %q", sh.GitBranch)
+ }
+ if sh.Model != "gpt-4" {
+ t.Errorf("Model = %q", sh.Model)
+ }
+ if sh.ReviewMode != ReviewModeWorkspace {
+ t.Errorf("ReviewMode = %q", sh.ReviewMode)
+ }
+ if sh.DiffFrom != "a" || sh.DiffTo != "b" || sh.DiffCommit != "c" {
+ t.Errorf("Diff fields mismatch")
+ }
+ if sh.StartTime.IsZero() {
+ t.Error("StartTime should be set")
+ }
+ if sh.FileSessions == nil {
+ t.Error("FileSessions map should be initialized")
+ }
+}
+
+func TestGetOrCreateFileSession(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+
+ fs1 := sh.GetOrCreateFileSession("main.go")
+ if fs1 == nil {
+ t.Fatal("nil FileSession")
+ }
+ if fs1.FilePath != "main.go" {
+ t.Errorf("FilePath = %q", fs1.FilePath)
+ }
+
+ fs2 := sh.GetOrCreateFileSession("main.go")
+ if fs1 != fs2 {
+ t.Error("expected same FileSession instance on second call")
+ }
+
+ fs3 := sh.GetOrCreateFileSession("other.go")
+ if fs3 == fs1 {
+ t.Error("different paths should yield different sessions")
+ }
+}
+
+func TestAppendTaskRecord(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "hello")}
+ rec := fs.AppendTaskRecord(MainTask, msgs)
+ if rec == nil {
+ t.Fatal("nil TaskRecord")
+ }
+ if rec.Type != MainTask {
+ t.Errorf("Type = %v", rec.Type)
+ }
+ if rec.RequestNo != 1 {
+ t.Errorf("RequestNo = %d, want 1", rec.RequestNo)
+ }
+
+ rec2 := fs.AppendTaskRecord(MainTask, msgs)
+ if rec2.RequestNo != 2 {
+ t.Errorf("second RequestNo = %d, want 2", rec2.RequestNo)
+ }
+
+ rec3 := fs.AppendTaskRecord(PlanTask, msgs)
+ if rec3.RequestNo != 1 {
+ t.Errorf("PlanTask RequestNo = %d, want 1 (separate counter)", rec3.RequestNo)
+ }
+}
+
+func TestAppendTaskRecord_DefensiveCopy(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+
+ msgs := []llm.Message{llm.NewTextMessage("user", "original")}
+ rec := fs.AppendTaskRecord(MainTask, msgs)
+ msgs[0] = llm.NewTextMessage("user", "mutated")
+
+ if rec.RequestMessages[0].ExtractText() == "mutated" {
+ t.Error("AppendTaskRecord should store a copy of messages")
+ }
+}
+
+func TestSetResponse(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+ rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
+
+ content := "response text"
+ resp := &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &content,
+ },
+ }},
+ Model: "gpt-4",
+ Usage: &llm.UsageInfo{
+ PromptTokens: 100,
+ CompletionTokens: 50,
+ },
+ }
+
+ rec.SetResponse(resp, 2*time.Second)
+
+ if rec.Response == nil {
+ t.Fatal("Response should be set")
+ }
+ if rec.Response.Content != "response text" {
+ t.Errorf("Content = %q", rec.Response.Content)
+ }
+ if rec.Response.Model != "gpt-4" {
+ t.Errorf("Model = %q", rec.Response.Model)
+ }
+ if rec.Response.Usage.PromptTokens != 100 {
+ t.Errorf("PromptTokens = %d", rec.Response.Usage.PromptTokens)
+ }
+ if rec.Response.Usage.CompletionTokens != 50 {
+ t.Errorf("CompletionTokens = %d", rec.Response.Usage.CompletionTokens)
+ }
+ if rec.Duration != 2*time.Second {
+ t.Errorf("Duration = %v", rec.Duration)
+ }
+}
+
+func TestSetResponse_EmptyResponse(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+ rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
+
+ rec.SetResponse(nil, time.Second)
+ if rec.Error == "" {
+ t.Error("expected error for nil response")
+ }
+}
+
+func TestSetError(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+ rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
+
+ rec.SetError(errors.New("timeout"), 5*time.Second)
+
+ if rec.Error != "timeout" {
+ t.Errorf("Error = %q, want %q", rec.Error, "timeout")
+ }
+ if rec.Duration != 5*time.Second {
+ t.Errorf("Duration = %v", rec.Duration)
+ }
+}
+
+func TestLLMFailures(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ if sh.LLMFailures() != 0 {
+ t.Errorf("initial failures = %d", sh.LLMFailures())
+ }
+
+ fs := sh.GetOrCreateFileSession("a.go")
+ rec := fs.AppendTaskRecord(MainTask, nil)
+ rec.SetError(errors.New("fail1"), time.Second)
+
+ rec2 := fs.AppendTaskRecord(MainTask, nil)
+ rec2.SetError(errors.New("fail2"), time.Second)
+
+ if sh.LLMFailures() != 2 {
+ t.Errorf("failures = %d, want 2", sh.LLMFailures())
+ }
+}
+
+func TestAddToolResult(t *testing.T) {
+ sh := New("/tmp/repo", "main", "model", SessionOptions{})
+ fs := sh.GetOrCreateFileSession("file.go")
+ rec := fs.AppendTaskRecord(MainTask, nil)
+
+ rec.AddToolResult("file_read", `{"path":"main.go"}`, "package main")
+
+ if len(rec.ToolResults) != 1 {
+ t.Fatalf("len = %d", len(rec.ToolResults))
+ }
+ tr := rec.ToolResults[0]
+ if tr.ToolName != "file_read" {
+ t.Errorf("ToolName = %q", tr.ToolName)
+ }
+ if tr.Arguments != `{"path":"main.go"}` {
+ t.Errorf("Arguments = %q", tr.Arguments)
+ }
+ if tr.Result != "package main" {
+ t.Errorf("Result = %q", tr.Result)
+ }
+}
diff --git a/internal/tool/code_comment_test.go b/internal/tool/code_comment_test.go
new file mode 100644
index 0000000..0c3877a
--- /dev/null
+++ b/internal/tool/code_comment_test.go
@@ -0,0 +1,190 @@
+package tool
+
+import (
+ "context"
+ "testing"
+)
+
+func TestParseComments(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ wantCount int
+ wantErr bool
+ }{
+ {
+ name: "valid comments array",
+ args: map[string]any{
+ "path": "main.go",
+ "comments": []any{
+ map[string]any{"content": "issue 1", "existing_code": "old"},
+ map[string]any{"content": "issue 2", "suggestion_code": "new"},
+ },
+ },
+ wantCount: 2,
+ },
+ {
+ name: "comments as JSON string",
+ args: map[string]any{
+ "path": "main.go",
+ "comments": `[{"content":"from string"}]`,
+ },
+ wantCount: 1,
+ },
+ {
+ name: "missing path skips comment",
+ args: map[string]any{
+ "comments": []any{
+ map[string]any{"content": "no path"},
+ },
+ },
+ wantCount: 0,
+ },
+ {
+ name: "missing content skips comment",
+ args: map[string]any{
+ "path": "file.go",
+ "comments": []any{
+ map[string]any{"existing_code": "has no content"},
+ },
+ },
+ wantCount: 0,
+ },
+ {
+ name: "empty comments array returns error",
+ args: map[string]any{"path": "x.go", "comments": []any{}},
+ wantErr: true,
+ },
+ {
+ name: "no comments key returns error",
+ args: map[string]any{"path": "x.go"},
+ wantErr: true,
+ },
+ {
+ name: "invalid JSON string returns error",
+ args: map[string]any{"path": "x.go", "comments": "not json"},
+ wantErr: true,
+ },
+ {
+ name: "thinking field preserved",
+ args: map[string]any{
+ "path": "a.go",
+ "comments": []any{
+ map[string]any{"content": "c", "thinking": "my reasoning"},
+ },
+ },
+ wantCount: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ comments, errMsg := ParseComments(tt.args)
+ if tt.wantErr {
+ if errMsg == "" {
+ t.Error("expected error message, got empty")
+ }
+ return
+ }
+ if errMsg != "" {
+ t.Fatalf("unexpected error: %s", errMsg)
+ }
+ if len(comments) != tt.wantCount {
+ t.Errorf("len(comments) = %d, want %d", len(comments), tt.wantCount)
+ }
+ })
+ }
+}
+
+func TestParseComments_Fields(t *testing.T) {
+ args := map[string]any{
+ "path": "src/app.ts",
+ "comments": []any{
+ map[string]any{
+ "content": "fix null check",
+ "existing_code": "if (x == null)",
+ "suggestion_code": "if (x === null)",
+ "thinking": "strict equality is safer",
+ },
+ },
+ }
+ comments, errMsg := ParseComments(args)
+ if errMsg != "" {
+ t.Fatal(errMsg)
+ }
+ if len(comments) != 1 {
+ t.Fatal("expected 1 comment")
+ }
+ c := comments[0]
+ if c.Path != "src/app.ts" {
+ t.Errorf("Path = %q", c.Path)
+ }
+ if c.Content != "fix null check" {
+ t.Errorf("Content = %q", c.Content)
+ }
+ if c.ExistingCode != "if (x == null)" {
+ t.Errorf("ExistingCode = %q", c.ExistingCode)
+ }
+ if c.SuggestionCode != "if (x === null)" {
+ t.Errorf("SuggestionCode = %q", c.SuggestionCode)
+ }
+ if c.Thinking != "strict equality is safer" {
+ t.Errorf("Thinking = %q", c.Thinking)
+ }
+}
+
+func TestCodeCommentProvider_Execute(t *testing.T) {
+ t.Run("adds comments to collector", func(t *testing.T) {
+ collector := NewCommentCollector()
+ p := &CodeCommentProvider{Collector: collector}
+ result, err := p.Execute(context.Background(), map[string]any{
+ "path": "main.go",
+ "comments": []any{
+ map[string]any{"content": "issue 1"},
+ map[string]any{"content": "issue 2"},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result != CommentSucceed {
+ t.Errorf("result = %q, want %q", result, CommentSucceed)
+ }
+ if len(collector.Comments()) != 2 {
+ t.Errorf("collector has %d comments, want 2", len(collector.Comments()))
+ }
+ })
+
+ t.Run("nil collector returns error message", func(t *testing.T) {
+ p := &CodeCommentProvider{Collector: nil}
+ result, err := p.Execute(context.Background(), map[string]any{
+ "path": "main.go",
+ "comments": []any{map[string]any{"content": "x"}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result == CommentSucceed {
+ t.Error("expected error message for nil collector")
+ }
+ })
+
+ t.Run("invalid args returns error message", func(t *testing.T) {
+ collector := NewCommentCollector()
+ p := &CodeCommentProvider{Collector: collector}
+ result, err := p.Execute(context.Background(), map[string]any{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result == CommentSucceed {
+ t.Error("expected error message for empty args")
+ }
+ })
+
+ t.Run("tool type is CodeComment", func(t *testing.T) {
+ p := &CodeCommentProvider{}
+ if p.Tool() != CodeComment {
+ t.Errorf("Tool() = %v, want CodeComment", p.Tool())
+ }
+ })
+}
diff --git a/internal/tool/filereader_read_test.go b/internal/tool/filereader_read_test.go
new file mode 100644
index 0000000..74bffbc
--- /dev/null
+++ b/internal/tool/filereader_read_test.go
@@ -0,0 +1,130 @@
+package tool
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFileReader_Read_Workspace(t *testing.T) {
+ dir := t.TempDir()
+ content := "line1\nline2\nline3\n"
+ if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ got, err := fr.Read(context.Background(), "test.go")
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+ if got != content {
+ t.Errorf("Read() = %q, want %q", got, content)
+ }
+}
+
+func TestFileReader_Read_WorkspaceNotFound(t *testing.T) {
+ dir := t.TempDir()
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ _, err := fr.Read(context.Background(), "missing.go")
+ if err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestFileReader_Read_PathTraversal(t *testing.T) {
+ dir := t.TempDir()
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+
+ _, err := fr.Read(context.Background(), "../../../etc/passwd")
+ if err == nil {
+ t.Error("expected error for path traversal")
+ }
+}
+
+func TestFileReader_Read_SymlinkOutsideRepo(t *testing.T) {
+ dir := t.TempDir()
+ outside := t.TempDir()
+ secretFile := filepath.Join(outside, "secret.txt")
+ if err := os.WriteFile(secretFile, []byte("sensitive"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ link := filepath.Join(dir, "link.txt")
+ if err := os.Symlink(secretFile, link); err != nil {
+ t.Skipf("symlinks not supported: %v", err)
+ }
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ _, err := fr.Read(context.Background(), "link.txt")
+ if err == nil {
+ t.Error("expected error for symlink pointing outside repo")
+ }
+}
+
+func TestFileReader_ReadLines_Workspace(t *testing.T) {
+ dir := t.TempDir()
+ content := "aaa\nbbb\nccc\nddd\n"
+ if err := os.WriteFile(filepath.Join(dir, "lines.txt"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+
+ t.Run("all lines", func(t *testing.T) {
+ lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 1, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 5 {
+ t.Errorf("total = %d, want 5", total)
+ }
+ if len(lines) != 5 {
+ t.Errorf("lines count = %d, want 5", len(lines))
+ }
+ })
+
+ t.Run("start from line 2 with limit", func(t *testing.T) {
+ lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 2, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 5 {
+ t.Errorf("total = %d, want 5", total)
+ }
+ if len(lines) != 2 {
+ t.Fatalf("lines count = %d, want 2", len(lines))
+ }
+ if lines[0] != "bbb" || lines[1] != "ccc" {
+ t.Errorf("lines = %v, want [bbb ccc]", lines)
+ }
+ })
+
+ t.Run("path traversal rejected", func(t *testing.T) {
+ _, _, err := fr.ReadLines(context.Background(), "../../etc/passwd", 1, 10)
+ if err == nil {
+ t.Error("expected error for path traversal")
+ }
+ })
+}
+
+func TestFileReader_Read_SubdirectoryFile(t *testing.T) {
+ dir := t.TempDir()
+ sub := filepath.Join(dir, "src", "pkg")
+ if err := os.MkdirAll(sub, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "main.go"), []byte("package main"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ got, err := fr.Read(context.Background(), "src/pkg/main.go")
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+ if got != "package main" {
+ t.Errorf("Read() = %q, want %q", got, "package main")
+ }
+}
diff --git a/internal/viewer/store_test.go b/internal/viewer/store_test.go
new file mode 100644
index 0000000..bb0d89b
--- /dev/null
+++ b/internal/viewer/store_test.go
@@ -0,0 +1,241 @@
+package viewer
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func writeJSONL(t *testing.T, path string, lines ...string) {
+ t.Helper()
+ var content string
+ for _, l := range lines {
+ content += l + "\n"
+ }
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDiscoverRepos_Empty(t *testing.T) {
+ root := t.TempDir()
+ repos, err := DiscoverRepos(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(repos) != 0 {
+ t.Errorf("expected 0 repos, got %d", len(repos))
+ }
+}
+
+func TestDiscoverRepos_NonExistentDir(t *testing.T) {
+ repos, err := DiscoverRepos("/nonexistent/path/abc123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repos != nil {
+ t.Errorf("expected nil for non-existent dir, got %v", repos)
+ }
+}
+
+func TestDiscoverRepos_SkipsFiles(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ repos, err := DiscoverRepos(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(repos) != 0 {
+ t.Errorf("expected 0 repos, got %d", len(repos))
+ }
+}
+
+func TestDiscoverRepos_FindsRepos(t *testing.T) {
+ root := t.TempDir()
+
+ repoA := filepath.Join(root, "repo-a")
+ repoB := filepath.Join(root, "repo-b")
+ if err := os.MkdirAll(repoA, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(repoB, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoA, "session1.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T10:00:00Z"}`)
+ writeJSONL(t, filepath.Join(repoA, "session2.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-02T10:00:00Z"}`)
+ writeJSONL(t, filepath.Join(repoB, "session3.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-03T10:00:00Z"}`)
+
+ repos, err := DiscoverRepos(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(repos) != 2 {
+ t.Fatalf("expected 2 repos, got %d", len(repos))
+ }
+ if repos[0].EncodedPath != "repo-b" {
+ t.Errorf("expected most recent repo first, got %q", repos[0].EncodedPath)
+ }
+ if repos[1].SessionCount != 2 {
+ t.Errorf("repo-a session count = %d, want 2", repos[1].SessionCount)
+ }
+}
+
+func TestDiscoverRepos_SkipsDirsWithNoJSONL(t *testing.T) {
+ root := t.TempDir()
+ emptyRepo := filepath.Join(root, "empty-repo")
+ if err := os.MkdirAll(emptyRepo, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(emptyRepo, "readme.txt"), []byte("hi"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ repos, err := DiscoverRepos(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(repos) != 0 {
+ t.Errorf("expected 0 repos for dir with no .jsonl, got %d", len(repos))
+ }
+}
+
+func TestListSessions(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "myrepo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "aaa.jsonl"),
+ `{"type":"session_start","timestamp":"2025-03-01T09:00:00Z","cwd":"/home/user/proj","gitBranch":"main","model":"gpt-4","reviewMode":"workspace"}`,
+ `{"type":"session_end","duration_seconds":120.5,"files_reviewed":["a.go","b.go"],"llm_failures":1}`)
+
+ writeJSONL(t, filepath.Join(repoDir, "bbb.jsonl"),
+ `{"type":"session_start","timestamp":"2025-03-02T10:00:00Z","cwd":"/home/user/proj","gitBranch":"feat","model":"claude","reviewMode":"commit","diffCommit":"abc123"}`,
+ `{"type":"session_end","duration_seconds":60.0,"files_reviewed":["c.go"],"llm_failures":0}`)
+
+ // Non-jsonl file should be skipped
+ if err := os.WriteFile(filepath.Join(repoDir, "notes.txt"), []byte("ignored"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ sessions, err := ListSessions(root, "myrepo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sessions) != 2 {
+ t.Fatalf("expected 2 sessions, got %d", len(sessions))
+ }
+
+ // Should be sorted newest first
+ if sessions[0].SessionID != "bbb" {
+ t.Errorf("expected newest session first, got %q", sessions[0].SessionID)
+ }
+ if sessions[0].Model != "claude" {
+ t.Errorf("Model = %q", sessions[0].Model)
+ }
+ if sessions[0].ReviewMode != "commit" {
+ t.Errorf("ReviewMode = %q", sessions[0].ReviewMode)
+ }
+ if sessions[0].DiffCommit != "abc123" {
+ t.Errorf("DiffCommit = %q", sessions[0].DiffCommit)
+ }
+ if sessions[0].DurationSec != 60.0 {
+ t.Errorf("DurationSec = %f", sessions[0].DurationSec)
+ }
+ if sessions[0].FileCount != 1 {
+ t.Errorf("FileCount = %d", sessions[0].FileCount)
+ }
+
+ if sessions[1].SessionID != "aaa" {
+ t.Errorf("second session = %q", sessions[1].SessionID)
+ }
+ if sessions[1].LLMFailures != 1 {
+ t.Errorf("LLMFailures = %d", sessions[1].LLMFailures)
+ }
+}
+
+func TestPeekSession(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "test.jsonl")
+ writeJSONL(t, path,
+ `{"type":"session_start","timestamp":"2025-06-15T14:30:00Z","cwd":"/repo","gitBranch":"dev","model":"gpt-4o","reviewMode":"range","diffFrom":"a1b2","diffTo":"c3d4"}`,
+ `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1}`,
+ `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"looks good"}`,
+ `{"type":"session_end","duration_seconds":45.2,"files_reviewed":["main.go","util.go"],"llm_failures":2}`)
+
+ s, err := peekSession(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC)
+ if !s.Timestamp.Equal(expected) {
+ t.Errorf("Timestamp = %v, want %v", s.Timestamp, expected)
+ }
+ if s.CWD != "/repo" {
+ t.Errorf("CWD = %q", s.CWD)
+ }
+ if s.GitBranch != "dev" {
+ t.Errorf("GitBranch = %q", s.GitBranch)
+ }
+ if s.Model != "gpt-4o" {
+ t.Errorf("Model = %q", s.Model)
+ }
+ if s.ReviewMode != "range" {
+ t.Errorf("ReviewMode = %q", s.ReviewMode)
+ }
+ if s.DiffFrom != "a1b2" {
+ t.Errorf("DiffFrom = %q", s.DiffFrom)
+ }
+ if s.DiffTo != "c3d4" {
+ t.Errorf("DiffTo = %q", s.DiffTo)
+ }
+ if s.DurationSec != 45.2 {
+ t.Errorf("DurationSec = %f", s.DurationSec)
+ }
+ if len(s.FilesReviewed) != 2 {
+ t.Errorf("FilesReviewed = %v", s.FilesReviewed)
+ }
+ if s.LLMFailures != 2 {
+ t.Errorf("LLMFailures = %d", s.LLMFailures)
+ }
+ if s.FileCount != 2 {
+ t.Errorf("FileCount = %d", s.FileCount)
+ }
+}
+
+func TestPeekSession_MissingFile(t *testing.T) {
+ _, err := peekSession("/nonexistent/path/session.jsonl")
+ if err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestPeekSession_NoSessionEnd(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "partial.jsonl")
+ writeJSONL(t, path,
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`)
+
+ s, err := peekSession(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.CWD != "/x" {
+ t.Errorf("CWD = %q", s.CWD)
+ }
+ if s.DurationSec != 0 {
+ t.Errorf("DurationSec should be 0 without session_end, got %f", s.DurationSec)
+ }
+ if s.FileCount != 0 {
+ t.Errorf("FileCount should be 0 without session_end, got %d", s.FileCount)
+ }
+}
From 5bd291739d2ab22a7b20d4e5c393d57a8ff67ac5 Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 23:19:22 +0800
Subject: [PATCH 05/87] chore: remove unused cmd/testdiff debug helper
The testdiff CLI was an early-stage tool for manually testing the
internal/diff package. It has no external references and its role is
fully covered by the existing unit tests in internal/diff/.
---
cmd/testdiff/main.go | 193 -------------------------------------------
1 file changed, 193 deletions(-)
delete mode 100644 cmd/testdiff/main.go
diff --git a/cmd/testdiff/main.go b/cmd/testdiff/main.go
deleted file mode 100644
index a1c3e77..0000000
--- a/cmd/testdiff/main.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package main
-
-// go build ./cmd/testdiff/ -o /tmp/testdiff && /tmp/testdiff ...
-// Or just: go run ./cmd/testdiff/ ...
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "github.com/open-code-review/open-code-review/internal/diff"
- "github.com/open-code-review/open-code-review/internal/model"
-)
-
-func main() {
- args := parseArgs(os.Args[1:])
- if args.showHelp || len(args.raw) == 0 {
- printUsage()
- os.Exit(0)
- }
-
- repoDir, err := resolveRepo(args.repo)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
-
- provider := buildProvider(repoDir, args)
- diffs, err := provider.GetDiff(context.Background())
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
-
- if len(diffs) == 0 {
- fmt.Println("(no changes)")
- return
- }
-
- if args.summary {
- printSummary(diffs)
- return
- }
-
- if args.format == "json" {
- out, _ := json.MarshalIndent(diffs, "", " ")
- fmt.Println(string(out))
- return
- }
-
- printText(diffs)
-}
-
-// ---- argument parsing ----
-
-type cliArgs struct {
- repo string
- from string
- to string
- commit string
- format string // "text" or "json"
- summary bool // just print file list and stats
- showHelp bool
- raw []string
-}
-
-func parseArgs(args []string) cliArgs {
- result := cliArgs{raw: args, format: "text"}
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-h", "--help":
- result.showHelp = true
- return result
- case "-repo":
- i++
- result.repo = args[i]
- case "-from":
- i++
- result.from = args[i]
- case "-to":
- i++
- result.to = args[i]
- case "-commit":
- i++
- result.commit = args[i]
- case "-format":
- i++
- result.format = args[i]
- case "-summary":
- result.summary = true
- }
- }
- return result
-}
-
-func printUsage() {
- fmt.Println(`testdiff — quick diff parsing test helper.
-
-Usage:
- go run ./cmd/testdiff [flags]
-
-Examples:
- # Workspace mode (default if no refs given, runs from CWD)
- go run ./cmd/testdiff
-
- # Range mode
- go run ./cmd/testdiff -from master -to dev-ref
-
- # Single commit vs its parent
- go run ./cmd/testdiff -commit abc1234
-
- # Summary only (file paths and line counts)
- go run ./cmd/testdiff -from master -to dev-ref -summary
-
-Flags:
- -repo DIR git repository root (default: auto-detect via git rev-parse)
- -from REF source ref (e.g. 'main')
- -to REF target ref (e.g. 'feature-branch')
- -commit SHA single commit to review (vs its parent)
- -format FMT output format: text or json (default: text)
- -summary show file list and insertions/deletions only`)
-}
-
-func resolveRepo(input string) (string, error) {
- if input == "" {
- out, err := exec.Command("git", "rev-parse", "--show-toplevel").CombinedOutput()
- if err != nil {
- return "", fmt.Errorf("not in a git repo (%s)", strings.TrimSpace(string(out)))
- }
- input = strings.TrimSpace(string(out))
- }
- abs, err := filepath.Abs(input)
- if err != nil {
- return "", err
- }
- return abs, nil
-}
-
-func buildProvider(repoDir string, args cliArgs) *diff.Provider {
- switch {
- case args.commit != "":
- return diff.NewCommitProvider(repoDir, args.commit, nil)
- case args.from != "" && args.to != "":
- return diff.NewProvider(repoDir, args.from, args.to, nil)
- default:
- return diff.NewWorkspaceProvider(repoDir, nil)
- }
-}
-
-// ---- output helpers ----
-
-func printSummary(diffs []model.Diff) {
- var totalAdd, totalDel int64
- for _, d := range diffs {
- status := "M"
- if d.IsNew {
- status = "A"
- } else if d.IsDeleted {
- status = "D"
- }
- path := d.NewPath
- if path == "/dev/null" {
- path = d.OldPath
- }
- fmt.Printf(" %s %-4s +%d/-%d %s\n", status, "", d.Insertions, d.Deletions, path)
- totalAdd += d.Insertions
- totalDel += d.Deletions
- }
- fmt.Printf("\n%d file(s), +%d/-%d lines\n", len(diffs), totalAdd, totalDel)
-}
-
-func printText(diffs []model.Diff) {
- for i, d := range diffs {
- path := d.NewPath
- if path == "/dev/null" {
- path = d.OldPath
- }
- status := "MODIFIED"
- if d.IsNew {
- status = "ADDED"
- } else if d.IsDeleted {
- status = "DELETED"
- }
- fmt.Printf("--- %s (%s, +%d/-%d) ---\n", path, status, d.Insertions, d.Deletions)
- fmt.Print(d.Diff)
- if i < len(diffs)-1 {
- fmt.Println()
- }
- }
-}
From b3eb4b3491da62e5957353c7ad35b79452f86de1 Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 23:26:49 +0800
Subject: [PATCH 06/87] test: add unit tests for output_helpers, config, model,
stdout, and telemetry packages
---
cmd/opencodereview/output_helpers_test.go | 328 ++++++++++++++++++
.../testconnection/testconnection_test.go | 92 +++++
.../config/toolsconfig/toolsconfig_test.go | 102 ++++++
internal/model/model_test.go | 192 ++++++++++
internal/stdout/stdout_test.go | 30 ++
internal/telemetry/config_test.go | 265 ++++++++++++++
internal/telemetry/events_test.go | 50 +++
internal/telemetry/metrics_test.go | 30 ++
internal/telemetry/provider_test.go | 52 +++
internal/telemetry/shutdown_test.go | 71 ++++
internal/telemetry/span_test.go | 43 +++
11 files changed, 1255 insertions(+)
create mode 100644 cmd/opencodereview/output_helpers_test.go
create mode 100644 internal/config/testconnection/testconnection_test.go
create mode 100644 internal/config/toolsconfig/toolsconfig_test.go
create mode 100644 internal/model/model_test.go
create mode 100644 internal/stdout/stdout_test.go
create mode 100644 internal/telemetry/config_test.go
create mode 100644 internal/telemetry/events_test.go
create mode 100644 internal/telemetry/metrics_test.go
create mode 100644 internal/telemetry/provider_test.go
create mode 100644 internal/telemetry/shutdown_test.go
create mode 100644 internal/telemetry/span_test.go
diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go
new file mode 100644
index 0000000..74d8106
--- /dev/null
+++ b/cmd/opencodereview/output_helpers_test.go
@@ -0,0 +1,328 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/agent"
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+func TestHasSubtaskErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ warnings []agent.AgentWarning
+ want bool
+ }{
+ {"nil warnings", nil, false},
+ {"empty", []agent.AgentWarning{}, false},
+ {"no subtask errors", []agent.AgentWarning{{Type: "other", Message: "msg"}}, false},
+ {"has subtask error", []agent.AgentWarning{{Type: "subtask_error", Message: "fail"}}, true},
+ {"mixed", []agent.AgentWarning{{Type: "warn"}, {Type: "subtask_error"}}, true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := hasSubtaskErrors(tc.warnings)
+ if got != tc.want {
+ t.Errorf("hasSubtaskErrors() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestWrapByRunes(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ maxW int
+ lines int
+ }{
+ {"empty", "", 80, 0},
+ {"short line", "hello", 80, 1},
+ {"exact width", strings.Repeat("a", 10), 10, 1},
+ {"wraps long line", strings.Repeat("word ", 25), 20, 7},
+ {"respects newlines", "line1\nline2\nline3", 80, 3},
+ {"wrap with newlines", "short\n" + strings.Repeat("x", 50), 20, 4},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := wrapByRunes(tc.text, tc.maxW)
+ if len(got) != tc.lines {
+ t.Errorf("wrapByRunes() got %d lines, want %d\nlines: %v", len(got), tc.lines, got)
+ }
+ })
+ }
+}
+
+func TestWrapSingleRuneLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ maxW int
+ min int
+ }{
+ {"short line unchanged", "hello", 100, 1},
+ {"wraps at space", "hello world foo bar baz", 12, 2},
+ {"no space to wrap", strings.Repeat("x", 30), 10, 3},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := wrapSingleRuneLine(tc.line, tc.maxW)
+ if len(got) < tc.min {
+ t.Errorf("got %d lines, want at least %d", len(got), tc.min)
+ }
+ })
+ }
+}
+
+func TestRuneWrapCut(t *testing.T) {
+ // Short line returns full length
+ runes := []rune("short")
+ cut := runeWrapCut(runes, 100)
+ if cut != len(runes) {
+ t.Errorf("expected %d, got %d", len(runes), cut)
+ }
+
+ // Cuts at space
+ runes = []rune("hello world test")
+ cut = runeWrapCut(runes, 11)
+ if runes[cut] != ' ' && cut != 11 {
+ t.Errorf("expected cut at space boundary, got %d (char=%c)", cut, runes[cut])
+ }
+}
+
+func TestVisibleRunesLen(t *testing.T) {
+ tests := []struct {
+ input string
+ want int
+ }{
+ {"hello", 5},
+ {"", 0},
+ {"\x01\x02\x03", 0},
+ {"a\x01b", 2},
+ {"\x7f", 0},
+ }
+ for _, tc := range tests {
+ got := visibleRunesLen([]rune(tc.input))
+ if got != tc.want {
+ t.Errorf("visibleRunesLen(%q) = %d, want %d", tc.input, got, tc.want)
+ }
+ }
+}
+
+func TestSplitToLines(t *testing.T) {
+ tests := []struct {
+ input string
+ want int
+ }{
+ {"a\nb\nc", 3},
+ {"a\nb\nc\n", 3},
+ {"single", 1},
+ {"crlf\r\nline", 2},
+ {"", 0},
+ }
+ for _, tc := range tests {
+ got := splitToLines(tc.input)
+ if len(got) != tc.want {
+ t.Errorf("splitToLines(%q) = %d lines, want %d", tc.input, len(got), tc.want)
+ }
+ }
+}
+
+func TestBuildDiffLines(t *testing.T) {
+ t.Run("empty suggestion returns nil", func(t *testing.T) {
+ c := model.LlmComment{ExistingCode: "old", SuggestionCode: ""}
+ got := buildDiffLines(c)
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+ })
+
+ t.Run("empty existing returns nil", func(t *testing.T) {
+ c := model.LlmComment{ExistingCode: "", SuggestionCode: "new"}
+ got := buildDiffLines(c)
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+ })
+
+ t.Run("diff computed", func(t *testing.T) {
+ c := model.LlmComment{
+ ExistingCode: "line1\nline2\n",
+ SuggestionCode: "line1\nmodified\n",
+ }
+ got := buildDiffLines(c)
+ if len(got) == 0 {
+ t.Error("expected non-empty diff lines")
+ }
+ })
+}
+
+func TestStatusBadge(t *testing.T) {
+ tests := []struct {
+ status string
+ substr string
+ }{
+ {"added", "[A]"},
+ {"modified", "[M]"},
+ {"deleted", "[D]"},
+ {"renamed", "[R]"},
+ {"binary", "[B]"},
+ {"scan", "[S]"},
+ {"unknown", "[?]"},
+ }
+ for _, tc := range tests {
+ got := statusBadge(tc.status)
+ if !strings.Contains(got, tc.substr) {
+ t.Errorf("statusBadge(%q) = %q, expected to contain %q", tc.status, got, tc.substr)
+ }
+ }
+}
+
+func TestOutputJSON(t *testing.T) {
+ // Redirect stdout to capture output
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ comments := []model.LlmComment{
+ {Path: "a.go", Content: "fix bug", StartLine: 1, EndLine: 5},
+ }
+ err := outputJSON(comments)
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("outputJSON error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal output: %v", err)
+ }
+ if out.Status != "success" {
+ t.Errorf("status = %q, want success", out.Status)
+ }
+ if len(out.Comments) != 1 {
+ t.Errorf("expected 1 comment, got %d", len(out.Comments))
+ }
+}
+
+func TestOutputJSON_NoComments(t *testing.T) {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := outputJSON(nil)
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("outputJSON error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ json.Unmarshal(buf.Bytes(), &out)
+ if out.Message == "" {
+ t.Error("expected non-empty message when no comments")
+ }
+}
+
+func TestOutputJSONWithWarnings(t *testing.T) {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
+ warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
+ err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3})
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ json.Unmarshal(buf.Bytes(), &out)
+ if out.Status != "completed_with_errors" {
+ t.Errorf("status = %q, want completed_with_errors", out.Status)
+ }
+ if out.Summary == nil {
+ t.Fatal("expected non-nil summary")
+ }
+ if out.Summary.FilesReviewed != 5 {
+ t.Errorf("FilesReviewed = %d, want 5", out.Summary.FilesReviewed)
+ }
+ if out.ToolCalls == nil || out.ToolCalls.Total != 3 {
+ t.Errorf("ToolCalls.Total = %v", out.ToolCalls)
+ }
+}
+
+func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
+ err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil)
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ json.Unmarshal(buf.Bytes(), &out)
+ if out.Status != "completed_with_warnings" {
+ t.Errorf("status = %q, want completed_with_warnings", out.Status)
+ }
+ if out.Message == "" {
+ t.Error("expected non-empty message")
+ }
+}
+
+func TestOutputJSONNoFiles(t *testing.T) {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := outputJSONNoFiles()
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ json.Unmarshal(buf.Bytes(), &out)
+ if out.Status != "skipped" {
+ t.Errorf("status = %q, want skipped", out.Status)
+ }
+}
diff --git a/internal/config/testconnection/testconnection_test.go b/internal/config/testconnection/testconnection_test.go
new file mode 100644
index 0000000..9af8567
--- /dev/null
+++ b/internal/config/testconnection/testconnection_test.go
@@ -0,0 +1,92 @@
+package testconnection
+
+import (
+ "testing"
+)
+
+func TestLoadDefault(t *testing.T) {
+ conv, err := LoadDefault()
+ if err != nil {
+ t.Fatalf("LoadDefault: %v", err)
+ }
+ if conv == nil {
+ t.Fatal("expected non-nil conversation")
+ }
+ if conv.Timeout <= 0 {
+ t.Errorf("expected positive timeout, got %d", conv.Timeout)
+ }
+ if len(conv.Messages) == 0 {
+ t.Fatal("expected at least one message")
+ }
+ hasSystem := false
+ hasUser := false
+ for _, m := range conv.Messages {
+ switch m.Role {
+ case "system":
+ hasSystem = true
+ case "user":
+ hasUser = true
+ }
+ }
+ if !hasSystem {
+ t.Error("expected a system message")
+ }
+ if !hasUser {
+ t.Error("expected a user message")
+ }
+}
+
+func TestResolveLang(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"", "English"},
+ {"Chinese", "Chinese"},
+ {"Japanese", "Japanese"},
+ }
+ for _, tc := range tests {
+ got := resolveLang(tc.input)
+ if got != tc.want {
+ t.Errorf("resolveLang(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ }
+}
+
+func TestApplyLanguage(t *testing.T) {
+ conv := &LlmConversation{
+ Messages: []ChatMessage{
+ {Role: "system", Content: "You are a bot."},
+ {Role: "user", Content: "Hello"},
+ },
+ }
+
+ conv.ApplyLanguage("Chinese")
+
+ if conv.Messages[0].Content == "You are a bot." {
+ t.Error("expected system message to be modified")
+ }
+ expected := "You are a bot.\n\nAlways respond in Chinese."
+ if conv.Messages[0].Content != expected {
+ t.Errorf("system content = %q, want %q", conv.Messages[0].Content, expected)
+ }
+ // User message should not be modified
+ if conv.Messages[1].Content != "Hello" {
+ t.Errorf("user content should not change, got %q", conv.Messages[1].Content)
+ }
+}
+
+func TestApplyLanguage_EmptyLang(t *testing.T) {
+ conv := &LlmConversation{
+ Messages: []ChatMessage{
+ {Role: "system", Content: "Base."},
+ },
+ }
+
+ conv.ApplyLanguage("")
+
+ expected := "Base.\n\nAlways respond in English."
+ if conv.Messages[0].Content != expected {
+ t.Errorf("content = %q, want %q", conv.Messages[0].Content, expected)
+ }
+}
diff --git a/internal/config/toolsconfig/toolsconfig_test.go b/internal/config/toolsconfig/toolsconfig_test.go
new file mode 100644
index 0000000..bd25bb1
--- /dev/null
+++ b/internal/config/toolsconfig/toolsconfig_test.go
@@ -0,0 +1,102 @@
+package toolsconfig
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoad_Default(t *testing.T) {
+ tools, err := Load("")
+ if err != nil {
+ t.Fatalf("Load default tools: %v", err)
+ }
+ if len(tools) == 0 {
+ t.Fatal("expected at least one tool from embedded config")
+ }
+ // Verify first tool has required fields
+ first := tools[0]
+ if first.Name == "" {
+ t.Error("expected non-empty tool name")
+ }
+ if first.Definition == nil {
+ t.Error("expected non-nil definition")
+ }
+}
+
+func TestLoad_CustomFile(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "tools.json")
+ data := `[
+ {"name": "test_tool", "plan_task": true, "main_task": false, "definition": {"name": "test_tool"}}
+ ]`
+ os.WriteFile(path, []byte(data), 0644)
+
+ tools, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load custom file: %v", err)
+ }
+ if len(tools) != 1 {
+ t.Fatalf("expected 1 tool, got %d", len(tools))
+ }
+ if tools[0].Name != "test_tool" {
+ t.Errorf("expected name=test_tool, got %s", tools[0].Name)
+ }
+ if !tools[0].PlanTask {
+ t.Error("expected PlanTask=true")
+ }
+ if tools[0].MainTask {
+ t.Error("expected MainTask=false")
+ }
+}
+
+func TestLoad_FileNotFound(t *testing.T) {
+ _, err := Load("/nonexistent/tools.json")
+ if err == nil {
+ t.Error("expected error for nonexistent file")
+ }
+}
+
+func TestLoad_InvalidJSON(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "tools.json")
+ os.WriteFile(path, []byte("not json"), 0644)
+
+ _, err := Load(path)
+ if err == nil {
+ t.Error("expected error for invalid JSON")
+ }
+}
+
+func TestToolDefsByPhase(t *testing.T) {
+ def := json.RawMessage(`{"name": "test"}`)
+ tests := []struct {
+ name string
+ entry ToolConfigEntry
+ planOnly bool
+ wantOk bool
+ }{
+ {"plan_task and planOnly=true", ToolConfigEntry{PlanTask: true, MainTask: false, Definition: def}, true, true},
+ {"plan_task and planOnly=false", ToolConfigEntry{PlanTask: true, MainTask: false, Definition: def}, false, false},
+ {"main_task and planOnly=false", ToolConfigEntry{PlanTask: false, MainTask: true, Definition: def}, false, true},
+ {"main_task and planOnly=true", ToolConfigEntry{PlanTask: false, MainTask: true, Definition: def}, true, false},
+ {"both and planOnly=true", ToolConfigEntry{PlanTask: true, MainTask: true, Definition: def}, true, true},
+ {"both and planOnly=false", ToolConfigEntry{PlanTask: true, MainTask: true, Definition: def}, false, true},
+ {"neither", ToolConfigEntry{PlanTask: false, MainTask: false, Definition: def}, true, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := tc.entry.ToolDefsByPhase(tc.planOnly)
+ if ok != tc.wantOk {
+ t.Errorf("ToolDefsByPhase(planOnly=%v) ok=%v, want %v", tc.planOnly, ok, tc.wantOk)
+ }
+ if tc.wantOk && got == nil {
+ t.Error("expected non-nil definition when ok=true")
+ }
+ if !tc.wantOk && got != nil {
+ t.Error("expected nil definition when ok=false")
+ }
+ })
+ }
+}
diff --git a/internal/model/model_test.go b/internal/model/model_test.go
new file mode 100644
index 0000000..8da62b8
--- /dev/null
+++ b/internal/model/model_test.go
@@ -0,0 +1,192 @@
+package model
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestDiff_JSONRoundTrip(t *testing.T) {
+ d := Diff{
+ OldPath: "a.go",
+ NewPath: "b.go",
+ Diff: "@@ -1 +1 @@\n-old\n+new",
+ IsBinary: false,
+ IsDeleted: false,
+ IsNew: true,
+ IsRenamed: true,
+ Insertions: 5,
+ Deletions: 3,
+ }
+
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got Diff
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got != d {
+ t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, d)
+ }
+}
+
+func TestPreviewEntry_JSONRoundTrip(t *testing.T) {
+ e := PreviewEntry{
+ Path: "main.go",
+ Status: "modified",
+ Insertions: 10,
+ Deletions: 2,
+ WillReview: true,
+ ExcludeReason: ExcludeNone,
+ }
+
+ data, err := json.Marshal(e)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got PreviewEntry
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got != e {
+ t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, e)
+ }
+}
+
+func TestPreviewEntry_ExcludeReasonOmitEmpty(t *testing.T) {
+ e := PreviewEntry{
+ Path: "a.go",
+ ExcludeReason: ExcludeNone,
+ }
+ data, _ := json.Marshal(e)
+ var m map[string]any
+ json.Unmarshal(data, &m)
+ if _, ok := m["exclude_reason"]; ok {
+ t.Error("expected exclude_reason to be omitted when empty")
+ }
+
+ e.ExcludeReason = ExcludeUserRule
+ data, _ = json.Marshal(e)
+ json.Unmarshal(data, &m)
+ if m["exclude_reason"] != "user_exclude" {
+ t.Errorf("expected exclude_reason=user_exclude, got %v", m["exclude_reason"])
+ }
+}
+
+func TestExcludeReasonConstants(t *testing.T) {
+ constants := map[ExcludeReason]string{
+ ExcludeNone: "",
+ ExcludeUserRule: "user_exclude",
+ ExcludeExtension: "unsupported_ext",
+ ExcludeDefaultPath: "default_path",
+ ExcludeDeleted: "deleted",
+ ExcludeBinary: "binary",
+ }
+ for k, v := range constants {
+ if string(k) != v {
+ t.Errorf("ExcludeReason constant mismatch: got %q, want %q", string(k), v)
+ }
+ }
+}
+
+func TestScanItem_AsDiff(t *testing.T) {
+ item := &ScanItem{
+ Path: "file.go",
+ Content: "package main\n",
+ IsBinary: false,
+ LineCount: 1,
+ }
+
+ d := item.AsDiff()
+ if d == nil {
+ t.Fatal("expected non-nil Diff")
+ }
+ if d.OldPath != "file.go" {
+ t.Errorf("OldPath = %q, want file.go", d.OldPath)
+ }
+ if d.NewPath != "file.go" {
+ t.Errorf("NewPath = %q, want file.go", d.NewPath)
+ }
+ if d.NewFileContent != "package main\n" {
+ t.Errorf("NewFileContent = %q", d.NewFileContent)
+ }
+ if d.IsBinary {
+ t.Error("expected IsBinary=false")
+ }
+ if d.Insertions != 1 {
+ t.Errorf("Insertions = %d, want 1", d.Insertions)
+ }
+}
+
+func TestScanItem_AsDiff_Nil(t *testing.T) {
+ var item *ScanItem
+ d := item.AsDiff()
+ if d != nil {
+ t.Error("expected nil Diff for nil ScanItem")
+ }
+}
+
+func TestScanItem_AsDiff_Binary(t *testing.T) {
+ item := &ScanItem{
+ Path: "image.png",
+ IsBinary: true,
+ }
+ d := item.AsDiff()
+ if !d.IsBinary {
+ t.Error("expected IsBinary=true")
+ }
+}
+
+func TestLlmComment_JSON(t *testing.T) {
+ c := LlmComment{
+ Path: "main.go",
+ Content: "fix this",
+ SuggestionCode: "new code",
+ ExistingCode: "old code",
+ StartLine: 10,
+ EndLine: 15,
+ Thinking: "reasoning",
+ }
+
+ data, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got LlmComment
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got != c {
+ t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, c)
+ }
+}
+
+func TestCodeReviewResult_JSON(t *testing.T) {
+ r := CodeReviewResult{
+ RelevantFile: "api.go",
+ SuggestionContent: "suggestion",
+ ExistingCode: "old",
+ SuggestionCode: "new",
+ }
+
+ data, err := json.Marshal(r)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got CodeReviewResult
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got != r {
+ t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, r)
+ }
+}
diff --git a/internal/stdout/stdout_test.go b/internal/stdout/stdout_test.go
new file mode 100644
index 0000000..aed675b
--- /dev/null
+++ b/internal/stdout/stdout_test.go
@@ -0,0 +1,30 @@
+package stdout
+
+import (
+ "io"
+ "os"
+ "testing"
+)
+
+func TestWriter_Default(t *testing.T) {
+ w := Writer()
+ if w != os.Stdout {
+ t.Error("expected default Writer to be os.Stdout")
+ }
+}
+
+func TestQuiet(t *testing.T) {
+ restore := Quiet()
+
+ w := Writer()
+ if w != io.Discard {
+ t.Error("expected Writer to be io.Discard after Quiet()")
+ }
+
+ restore()
+
+ w = Writer()
+ if w != os.Stdout {
+ t.Error("expected Writer to be os.Stdout after restore")
+ }
+}
diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go
new file mode 100644
index 0000000..4edb8ce
--- /dev/null
+++ b/internal/telemetry/config_test.go
@@ -0,0 +1,265 @@
+package telemetry
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDefaultConfig(t *testing.T) {
+ cfg := DefaultConfig()
+ if cfg.Enabled {
+ t.Error("expected Enabled=false by default")
+ }
+ if cfg.ServiceName != "open-code-review" {
+ t.Errorf("expected ServiceName=open-code-review, got %s", cfg.ServiceName)
+ }
+ if cfg.Exporter != "console" {
+ t.Errorf("expected Exporter=console, got %s", cfg.Exporter)
+ }
+ if cfg.OTLPEndpoint != "" {
+ t.Errorf("expected empty OTLPEndpoint, got %s", cfg.OTLPEndpoint)
+ }
+ if cfg.OTLPProtocol != "grpc" {
+ t.Errorf("expected OTLPProtocol=grpc, got %s", cfg.OTLPProtocol)
+ }
+ if cfg.ContentLog {
+ t.Error("expected ContentLog=false by default")
+ }
+}
+
+func TestResolveEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ envs map[string]string
+ check func(t *testing.T, cfg Config)
+ }{
+ {
+ name: "enable telemetry",
+ envs: map[string]string{"OCR_ENABLE_TELEMETRY": "1"},
+ check: func(t *testing.T, cfg Config) {
+ if !cfg.Enabled {
+ t.Error("expected Enabled=true")
+ }
+ },
+ },
+ {
+ name: "custom service name",
+ envs: map[string]string{"OTEL_SERVICE_NAME": "my-service"},
+ check: func(t *testing.T, cfg Config) {
+ if cfg.ServiceName != "my-service" {
+ t.Errorf("expected ServiceName=my-service, got %s", cfg.ServiceName)
+ }
+ },
+ },
+ {
+ name: "otlp endpoint sets exporter to otlp",
+ envs: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"},
+ check: func(t *testing.T, cfg Config) {
+ if cfg.Exporter != "otlp" {
+ t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
+ }
+ if cfg.OTLPEndpoint != "localhost:4317" {
+ t.Errorf("expected OTLPEndpoint=localhost:4317, got %s", cfg.OTLPEndpoint)
+ }
+ },
+ },
+ {
+ name: "otlp protocol override",
+ envs: map[string]string{"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"},
+ check: func(t *testing.T, cfg Config) {
+ if cfg.OTLPProtocol != "http/json" {
+ t.Errorf("expected OTLPProtocol=http/json, got %s", cfg.OTLPProtocol)
+ }
+ },
+ },
+ {
+ name: "content logging enabled",
+ envs: map[string]string{"OCR_CONTENT_LOGGING": "1"},
+ check: func(t *testing.T, cfg Config) {
+ if !cfg.ContentLog {
+ t.Error("expected ContentLog=true")
+ }
+ },
+ },
+ {
+ name: "no env vars leaves defaults",
+ envs: map[string]string{},
+ check: func(t *testing.T, cfg Config) {
+ if cfg.Enabled {
+ t.Error("expected Enabled=false")
+ }
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Clear relevant env vars
+ envKeys := []string{
+ "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME",
+ "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
+ "OCR_CONTENT_LOGGING",
+ }
+ for _, k := range envKeys {
+ t.Setenv(k, "")
+ os.Unsetenv(k)
+ }
+ for k, v := range tc.envs {
+ t.Setenv(k, v)
+ }
+
+ cfg := DefaultConfig()
+ resolveEnv(&cfg)
+ tc.check(t, cfg)
+ })
+ }
+}
+
+func TestLoadFromJSON(t *testing.T) {
+ t.Run("nonexistent file returns nil error", func(t *testing.T) {
+ cfg := DefaultConfig()
+ err := LoadFromJSON(&cfg, "/nonexistent/path/config.json")
+ if err != nil {
+ t.Errorf("expected nil error for missing file, got %v", err)
+ }
+ })
+
+ t.Run("malformed json returns nil error", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ os.WriteFile(path, []byte("{invalid json"), 0644)
+
+ cfg := DefaultConfig()
+ err := LoadFromJSON(&cfg, path)
+ if err != nil {
+ t.Errorf("expected nil error for malformed JSON, got %v", err)
+ }
+ })
+
+ t.Run("no telemetry section", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ os.WriteFile(path, []byte(`{"other": "value"}`), 0644)
+
+ cfg := DefaultConfig()
+ err := LoadFromJSON(&cfg, path)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if cfg.Enabled {
+ t.Error("expected Enabled to remain false")
+ }
+ })
+
+ t.Run("all fields set", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ data := `{
+ "telemetry": {
+ "enabled": true,
+ "exporter": "otlp",
+ "otlp_endpoint": "collector:4317",
+ "content_logging": true
+ }
+ }`
+ os.WriteFile(path, []byte(data), 0644)
+
+ cfg := DefaultConfig()
+ err := LoadFromJSON(&cfg, path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !cfg.Enabled {
+ t.Error("expected Enabled=true")
+ }
+ if cfg.Exporter != "otlp" {
+ t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
+ }
+ if cfg.OTLPEndpoint != "collector:4317" {
+ t.Errorf("expected OTLPEndpoint=collector:4317, got %s", cfg.OTLPEndpoint)
+ }
+ if !cfg.ContentLog {
+ t.Error("expected ContentLog=true")
+ }
+ })
+
+ t.Run("otlp_endpoint auto-sets exporter to otlp", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ data := `{"telemetry": {"otlp_endpoint": "localhost:4317"}}`
+ os.WriteFile(path, []byte(data), 0644)
+
+ cfg := DefaultConfig()
+ err := LoadFromJSON(&cfg, path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Exporter != "otlp" {
+ t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
+ }
+ })
+
+ t.Run("exporter not overridden if already non-default", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ data := `{"telemetry": {"exporter": "otlp"}}`
+ os.WriteFile(path, []byte(data), 0644)
+
+ cfg := DefaultConfig()
+ cfg.Exporter = "custom"
+ err := LoadFromJSON(&cfg, path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Exporter != "custom" {
+ t.Errorf("expected Exporter to remain custom, got %s", cfg.Exporter)
+ }
+ })
+}
+
+func TestResolveConfig(t *testing.T) {
+ t.Run("empty path uses only env and defaults", func(t *testing.T) {
+ envKeys := []string{
+ "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME",
+ "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
+ "OCR_CONTENT_LOGGING",
+ }
+ for _, k := range envKeys {
+ t.Setenv(k, "")
+ os.Unsetenv(k)
+ }
+
+ cfg := ResolveConfig("")
+ if cfg.Enabled {
+ t.Error("expected disabled with no env")
+ }
+ })
+
+ t.Run("env overrides json", func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "config.json")
+ data := `{"telemetry": {"enabled": false}}`
+ os.WriteFile(path, []byte(data), 0644)
+
+ t.Setenv("OCR_ENABLE_TELEMETRY", "1")
+
+ cfg := ResolveConfig(path)
+ if !cfg.Enabled {
+ t.Error("expected env to override json: Enabled should be true")
+ }
+ })
+}
+
+func TestHomeConfigPath(t *testing.T) {
+ path := HomeConfigPath()
+ if path == "" {
+ t.Skip("could not determine home dir")
+ }
+ if !filepath.IsAbs(path) {
+ t.Errorf("expected absolute path, got %s", path)
+ }
+ if filepath.Base(path) != "config.json" {
+ t.Errorf("expected config.json at end, got %s", filepath.Base(path))
+ }
+}
diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go
new file mode 100644
index 0000000..88e8b02
--- /dev/null
+++ b/internal/telemetry/events_test.go
@@ -0,0 +1,50 @@
+package telemetry
+
+import (
+ "testing"
+ "time"
+)
+
+func TestFormatDuration(t *testing.T) {
+ tests := []struct {
+ dur time.Duration
+ want string
+ }{
+ {0, "0s"},
+ {1500 * time.Millisecond, "1.5s"},
+ {60 * time.Second, "1m0s"},
+ {123 * time.Millisecond, "123ms"},
+ {2*time.Minute + 30*time.Second, "2m30s"},
+ }
+ for _, tc := range tests {
+ got := FormatDuration(tc.dur)
+ if got != tc.want {
+ t.Errorf("FormatDuration(%v) = %q, want %q", tc.dur, got, tc.want)
+ }
+ }
+}
+
+func TestSummarizeArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ want string
+ }{
+ {"nil map", nil, ""},
+ {"empty map", map[string]any{}, ""},
+ {"path key returns quoted", map[string]any{"path": "foo/bar.go"}, `"foo/bar.go"`},
+ {"search key returns quoted", map[string]any{"search": "hello"}, `"hello"`},
+ {"query key returns quoted", map[string]any{"query": "world"}, `"world"`},
+ {"pattern key returns quoted", map[string]any{"pattern": "*.go"}, `"*.go"`},
+ {"generic short value", map[string]any{"foo": "bar"}, "foo=bar"},
+ {"long value skipped", map[string]any{"data": string(make([]byte, 60))}, ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := summarizeArgs(tc.args)
+ if got != tc.want {
+ t.Errorf("summarizeArgs(%v) = %q, want %q", tc.args, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go
new file mode 100644
index 0000000..c678964
--- /dev/null
+++ b/internal/telemetry/metrics_test.go
@@ -0,0 +1,30 @@
+package telemetry
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestRecordFunctions_DisabledTelemetry(t *testing.T) {
+ // Reset state so telemetry is disabled
+ initialized = false
+ shutdownFuncs = nil
+
+ ctx := context.Background()
+
+ // These should all be no-ops when telemetry is disabled
+ RecordReviewDuration(ctx, 5*time.Second)
+ RecordFilesReviewed(ctx, 10)
+ RecordCommentsGenerated(ctx, 3)
+ RecordLLMRequest(ctx, "gpt-4", 2*time.Second, 1000, "ok")
+ RecordToolCall(ctx, "file_read", 100*time.Millisecond, true)
+ RecordToolCall(ctx, "file_read", 100*time.Millisecond, false)
+}
+
+func TestCheckMetricErr(t *testing.T) {
+ // Should not panic
+ checkMetricErr(nil)
+ checkMetricErr(fmt.Errorf("some error"))
+}
diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go
new file mode 100644
index 0000000..1454e54
--- /dev/null
+++ b/internal/telemetry/provider_test.go
@@ -0,0 +1,52 @@
+package telemetry
+
+import (
+ "context"
+ "testing"
+)
+
+func TestIsEnabled_NotInitialized(t *testing.T) {
+ // Reset global state
+ initialized = false
+ shutdownFuncs = nil
+
+ if IsEnabled() {
+ t.Error("expected IsEnabled()=false when not initialized")
+ }
+}
+
+func TestIsEnabled_InitializedButNoShutdowns(t *testing.T) {
+ initialized = true
+ shutdownFuncs = nil
+ defer func() {
+ initialized = false
+ }()
+
+ if IsEnabled() {
+ t.Error("expected IsEnabled()=false when no shutdown funcs registered")
+ }
+}
+
+func TestIsEnabled_WithShutdowns(t *testing.T) {
+ initialized = true
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error { return nil },
+ }
+ defer func() {
+ initialized = false
+ shutdownFuncs = nil
+ }()
+
+ if !IsEnabled() {
+ t.Error("expected IsEnabled()=true when shutdown funcs registered")
+ }
+}
+
+func TestContentLogging_Disabled(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+
+ if ContentLogging() {
+ t.Error("expected ContentLogging()=false when telemetry disabled")
+ }
+}
diff --git a/internal/telemetry/shutdown_test.go b/internal/telemetry/shutdown_test.go
new file mode 100644
index 0000000..a56f999
--- /dev/null
+++ b/internal/telemetry/shutdown_test.go
@@ -0,0 +1,71 @@
+package telemetry
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestShutdown_NoFuncs(t *testing.T) {
+ shutdownFuncs = nil
+ err := Shutdown(context.Background())
+ if err != nil {
+ t.Errorf("expected nil error, got %v", err)
+ }
+}
+
+func TestShutdown_Success(t *testing.T) {
+ called := false
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error {
+ called = true
+ return nil
+ },
+ }
+ defer func() { shutdownFuncs = nil }()
+
+ err := Shutdown(context.Background())
+ if err != nil {
+ t.Errorf("expected nil error, got %v", err)
+ }
+ if !called {
+ t.Error("expected shutdown function to be called")
+ }
+ if shutdownFuncs != nil {
+ t.Error("expected shutdownFuncs to be cleared after shutdown")
+ }
+}
+
+func TestShutdown_WithErrors(t *testing.T) {
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error { return nil },
+ func(ctx context.Context) error { return errors.New("fail1") },
+ func(ctx context.Context) error { return errors.New("fail2") },
+ }
+ defer func() { shutdownFuncs = nil }()
+
+ err := Shutdown(context.Background())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if shutdownFuncs != nil {
+ t.Error("expected shutdownFuncs to be cleared even on error")
+ }
+}
+
+func TestShutdownWithTimeout(t *testing.T) {
+ called := false
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error {
+ called = true
+ return nil
+ },
+ }
+ defer func() { shutdownFuncs = nil }()
+
+ ShutdownWithTimeout(context.Background(), 5*time.Second)
+ if !called {
+ t.Error("expected shutdown function to be called via ShutdownWithTimeout")
+ }
+}
diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go
new file mode 100644
index 0000000..033561b
--- /dev/null
+++ b/internal/telemetry/span_test.go
@@ -0,0 +1,43 @@
+package telemetry
+
+import (
+ "fmt"
+ "testing"
+
+ "go.opentelemetry.io/otel/attribute"
+)
+
+func TestAnyToAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ val interface{}
+ want attribute.KeyValue
+ }{
+ {"string", "k", "hello", attribute.String("k", "hello")},
+ {"int", "k", 42, attribute.Int64("k", 42)},
+ {"int64", "k", int64(100), attribute.Int64("k", 100)},
+ {"bool", "k", true, attribute.Bool("k", true)},
+ {"float64", "k", 3.14, attribute.Float64("k", 3.14)},
+ {"default fallback", "k", []int{1, 2}, attribute.String("k", fmt.Sprintf("%v", []int{1, 2}))},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := AnyToAttr(tc.key, tc.val)
+ if got != tc.want {
+ t.Errorf("AnyToAttr(%q, %v) = %v, want %v", tc.key, tc.val, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSetAttr_NilSpan(t *testing.T) {
+ // Should not panic
+ SetAttr(nil, "key", "value")
+}
+
+func TestRecordToolResult_NilSpan(t *testing.T) {
+ // Should not panic
+ RecordToolResult(nil, "tool", 100, nil)
+ RecordToolResult(nil, "tool", 100, fmt.Errorf("err"))
+}
From 110e5284fb4499e9e1479bb31804c92122cf9c96 Mon Sep 17 00:00:00 2001
From: kite
Date: Fri, 26 Jun 2026 23:31:51 +0800
Subject: [PATCH 07/87] fix: stabilize TestDiscoverRepos_FindsRepos with
explicit mtime ordering
The test relied on filesystem ModTime for sorting repos, but files
created in rapid succession can share the same mtime on CI, making
the sort order non-deterministic. Use os.Chtimes to guarantee repo-b
has a strictly later mtime than repo-a.
---
internal/viewer/store_test.go | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/internal/viewer/store_test.go b/internal/viewer/store_test.go
index bb0d89b..574f0a4 100644
--- a/internal/viewer/store_test.go
+++ b/internal/viewer/store_test.go
@@ -72,6 +72,12 @@ func TestDiscoverRepos_FindsRepos(t *testing.T) {
writeJSONL(t, filepath.Join(repoB, "session3.jsonl"),
`{"type":"session_start","timestamp":"2025-01-03T10:00:00Z"}`)
+ // Ensure repo-b's file has a strictly later mtime so sort-by-ModTime is deterministic.
+ future := time.Now().Add(time.Hour)
+ if err := os.Chtimes(filepath.Join(repoB, "session3.jsonl"), future, future); err != nil {
+ t.Fatal(err)
+ }
+
repos, err := DiscoverRepos(root)
if err != nil {
t.Fatal(err)
From 453c4f9c7646152369a38f98737cc54edeaa5434 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 00:15:28 +0800
Subject: [PATCH 08/87] test: add coverage tests for agent, llm, llmloop, and
scan packages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Raise statement coverage to 80%+ across four core packages to meet
FLOSS best practice badge criteria. Key additions:
- internal/scan (67% → 90%): getters, lookupDiff, filterScanItems,
whyExcluded all branches, extFromPath, maybeRunPlan/ProjectSummary/
Dedup success paths, executeSubtask, Run pipeline, dispatchSubtasks.
- internal/agent (60% → 83%): getters, filterDiffs, findDiff,
resolveSystemRule, injectDiffMap, executeReviewFilter, executePlanPhase,
executeSubtask, dispatchSubtasks.
- internal/llmloop (59% → 80%): warnings, tool calls, usage recording,
compression lifecycle (cancel/tryApply/run/trigger), partitionMessages.
- internal/llm (67% → 80%): parseBpeData, embedded BPE loader, message
constructors, ExtractText, ChatResponse helpers, parseShellRC.
---
internal/agent/coverage_test.go | 633 ++++++++++++++++++++++++
internal/llm/embedded_loader_test.go | 78 +++
internal/llm/message_test.go | 326 +++++++++++++
internal/llmloop/runner_test.go | 471 ++++++++++++++++++
internal/scan/coverage_test.go | 703 +++++++++++++++++++++++++++
5 files changed, 2211 insertions(+)
create mode 100644 internal/agent/coverage_test.go
create mode 100644 internal/llm/embedded_loader_test.go
create mode 100644 internal/llm/message_test.go
create mode 100644 internal/llmloop/runner_test.go
create mode 100644 internal/scan/coverage_test.go
diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go
new file mode 100644
index 0000000..f580942
--- /dev/null
+++ b/internal/agent/coverage_test.go
@@ -0,0 +1,633 @@
+package agent
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/rules"
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+func TestAgent_Getters(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
+ collector := tool.NewCommentCollector()
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test-model",
+ CommentCollector: collector,
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 10,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "test"}},
+ },
+ },
+ })
+
+ a.diffs = []model.Diff{
+ {NewPath: "a.go", Diff: "+code"},
+ {NewPath: "b.go", Diff: "+more"},
+ }
+
+ if a.Session() != sess {
+ t.Error("Session() does not return expected session")
+ }
+ if a.FilesReviewed() != 2 {
+ t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed())
+ }
+ if len(a.Diffs()) != 2 {
+ t.Errorf("Diffs() len = %d, want 2", len(a.Diffs()))
+ }
+ if a.ProjectSummary() != "" {
+ t.Errorf("ProjectSummary() = %q, want empty", a.ProjectSummary())
+ }
+ if a.TotalTokensUsed() != 0 {
+ t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed())
+ }
+ if a.TotalCacheReadTokens() != 0 {
+ t.Errorf("TotalCacheReadTokens() = %d, want 0", a.TotalCacheReadTokens())
+ }
+ if a.TotalCacheWriteTokens() != 0 {
+ t.Errorf("TotalCacheWriteTokens() = %d, want 0", a.TotalCacheWriteTokens())
+ }
+ if len(a.Warnings()) != 0 {
+ t.Errorf("Warnings() should be empty initially, got %d", len(a.Warnings()))
+ }
+ if len(a.ToolCalls()) != 0 {
+ t.Errorf("ToolCalls() should be empty initially, got %d", len(a.ToolCalls()))
+ }
+}
+
+func TestAgent_RecordWarning(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test-model",
+ Session: sess,
+ Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
+ })
+
+ a.recordWarning("error", "main.go", "something")
+ warnings := a.Warnings()
+ if len(warnings) != 1 {
+ t.Fatalf("expected 1 warning, got %d", len(warnings))
+ }
+ if warnings[0].Type != "error" || warnings[0].File != "main.go" {
+ t.Errorf("unexpected warning: %+v", warnings[0])
+ }
+}
+
+func TestNewCommentWorkerPool(t *testing.T) {
+ pool := NewCommentWorkerPool(2)
+ if pool == nil {
+ t.Fatal("NewCommentWorkerPool returned nil")
+ }
+}
+
+func TestInjectDiffMap(t *testing.T) {
+ reg := tool.NewRegistry()
+ emptyDM := tool.NewDiffMap(nil)
+ frd := tool.NewFileReadDiff(emptyDM)
+ reg.Register(frd)
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Tools: reg,
+ Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
+ })
+ a.diffs = []model.Diff{
+ {NewPath: "main.go", OldPath: "main.go", Diff: "+new code"},
+ {NewPath: "/dev/null", OldPath: "deleted.go", Diff: "-deleted"},
+ }
+
+ a.injectDiffMap()
+
+ result, err := frd.Execute(context.Background(), map[string]any{
+ "path_array": []any{"main.go"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(result, "+new code") {
+ t.Errorf("DiffMap did not contain main.go diff, got: %q", result)
+ }
+
+ result2, _ := frd.Execute(context.Background(), map[string]any{
+ "path_array": []any{"deleted.go"},
+ })
+ if !strings.Contains(result2, "not found") {
+ t.Errorf("/dev/null path should not be in DiffMap, got: %q", result2)
+ }
+}
+
+func TestFilterDiffs(t *testing.T) {
+ a := New(Args{
+ FileFilter: &rules.FileFilter{
+ Exclude: []string{"vendor/**"},
+ },
+ })
+ a.diffs = []model.Diff{
+ {NewPath: "main.go"},
+ {NewPath: "vendor/dep.go"},
+ {NewPath: "image.png", IsBinary: true},
+ {NewPath: "handler.go"},
+ }
+
+ kept := a.filterDiffs(a.diffs)
+
+ names := make(map[string]bool)
+ for _, d := range kept {
+ names[d.NewPath] = true
+ }
+ if names["vendor/dep.go"] {
+ t.Error("vendor file should be filtered")
+ }
+ if names["image.png"] {
+ t.Error("binary file should be filtered")
+ }
+ if !names["main.go"] || !names["handler.go"] {
+ t.Error("valid files should be kept")
+ }
+}
+
+func TestResolveSystemRule(t *testing.T) {
+ t.Run("nil SystemRule returns empty", func(t *testing.T) {
+ a := New(Args{SystemRule: nil})
+ if got := a.resolveSystemRule("main.go"); got != "" {
+ t.Errorf("expected empty, got %q", got)
+ }
+ })
+
+ t.Run("with resolver", func(t *testing.T) {
+ rule, err := rules.LoadDefault()
+ if err != nil {
+ t.Skipf("cannot load default rules: %v", err)
+ }
+ a := New(Args{SystemRule: rule})
+ got := a.resolveSystemRule("main.go")
+ if got == "" {
+ t.Error("expected non-empty rule for .go file")
+ }
+ })
+}
+
+func TestFindDiff(t *testing.T) {
+ a := New(Args{})
+ a.diffs = []model.Diff{
+ {NewPath: "a.go", OldPath: "a.go", Diff: "+a"},
+ {NewPath: "b.go", OldPath: "old_b.go", Diff: "+b"},
+ }
+
+ if d := a.findDiff("a.go"); d == nil || d.NewPath != "a.go" {
+ t.Error("findDiff should find by NewPath")
+ }
+ if d := a.findDiff("old_b.go"); d == nil || d.NewPath != "b.go" {
+ t.Error("findDiff should find by OldPath")
+ }
+ if d := a.findDiff("nonexist.go"); d != nil {
+ t.Error("findDiff should return nil for missing path")
+ }
+}
+
+func TestExecuteReviewFilter_NoFilterTask(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+ client := &fakeAgentClient{}
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ ReviewFilterTask: nil,
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go"}, "a.go")
+ if client.calls != 0 {
+ t.Errorf("no LLM calls expected when ReviewFilterTask is nil, got %d", client.calls)
+ }
+}
+
+func TestExecuteReviewFilter_NoComments(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+ client := &fakeAgentClient{}
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ ReviewFilterTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "Filter {{comments}} for {{path}} in {{diff}}"}},
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
+ if client.calls != 0 {
+ t.Errorf("no LLM calls expected when no comments exist, got %d", client.calls)
+ }
+}
+
+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{Content: &filterResp},
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
+ }},
+ }
+
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "keep this"})
+ collector.Add(model.LlmComment{Path: "a.go", Content: "remove this"})
+ collector.Add(model.LlmComment{Path: "a.go", Content: "also keep"})
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ CommentCollector: collector,
+ Template: template.Template{
+ ReviewFilterTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "Filter: {{comments}} path={{path}} diff={{diff}}"}},
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+code"}, "a.go")
+
+ comments := collector.CommentsForPath("a.go")
+ if len(comments) != 2 {
+ t.Fatalf("expected 2 comments after filter, got %d", len(comments))
+ }
+ for _, c := range comments {
+ if c.Content == "remove this" {
+ t.Error("filtered comment should have been removed")
+ }
+ }
+}
+
+func TestExecuteReviewFilter_LLMError(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ client := &fakeAgentClient{
+ responses: nil,
+ }
+
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ CommentCollector: collector,
+ Template: template.Template{
+ ReviewFilterTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
+
+ comments := collector.CommentsForPath("a.go")
+ if len(comments) != 1 {
+ t.Errorf("comments should be unchanged on LLM error, got %d", len(comments))
+ }
+}
+
+func TestExecutePlanPhase(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ planText := "review plan output"
+ client := &fakeAgentClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{Content: &planText},
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10},
+ }},
+ }
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ Background: "test background",
+ Template: template.Template{
+ PlanTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "system", Content: "You are a planner. Date: {{current_system_date_time}}"},
+ {Role: "user", Content: "Plan review for {{current_file_path}}. Rule: {{system_rule}}. Changes: {{change_files}}. Diff: {{diff}}. Background: {{requirement_background}}. Tools: {{plan_tools}}"},
+ },
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+ a.currentDate = "2025-06-26 10:00"
+
+ result, err := a.executePlanPhase(context.Background(), "main.go", "+new code", "helper.go", "check for bugs")
+ if err != nil {
+ t.Fatalf("executePlanPhase: %v", err)
+ }
+ if result != "review plan output" {
+ t.Errorf("result = %q", result)
+ }
+ if a.TotalInputTokens() != 20 {
+ t.Errorf("TotalInputTokens = %d, want 20", a.TotalInputTokens())
+ }
+}
+
+func TestExecutePlanPhase_LLMError(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ client := &fakeAgentClient{responses: nil}
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ PlanTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}},
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ _, err := a.executePlanPhase(context.Background(), "a.go", "+x", "", "")
+ if err != nil {
+ t.Logf("expected no-error from empty response, got: %v", err)
+ }
+}
+
+func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: nil},
+ },
+ })
+ a.currentDate = "2025-06-26 10:00"
+
+ err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
+ if err == nil {
+ t.Fatal("expected error for empty main_task messages")
+ }
+ if !strings.Contains(err.Error(), "main_task.messages is empty") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 10,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Review: {{diff}}"},
+ },
+ },
+ },
+ })
+ a.currentDate = "2025-06-26 10:00"
+ a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}}
+
+ err := a.executeSubtask(context.Background(), a.diffs[0])
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ warnings := a.Warnings()
+ found := false
+ for _, w := range warnings {
+ if w.Type == "token_threshold_exceeded" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected token_threshold_exceeded warning")
+ }
+}
+
+func TestExecuteSubtask_WithPlanPhase(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ planText := "my plan"
+ doneContent := ""
+ client := &fakeAgentClient{
+ responses: []*llm.ChatResponse{
+ {
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planText}}},
+ Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 3},
+ },
+ {
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &doneContent,
+ ToolCalls: []llm.ToolCall{{
+ ID: "c1", Type: "function",
+ Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
+ }},
+ },
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
+ },
+ },
+ }
+
+ reg := tool.NewRegistry()
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ Tools: reg,
+ Template: template.Template{
+ MaxTokens: 100000,
+ MaxToolRequestTimes: 10,
+ PlanModeLineThreshold: 0,
+ PlanTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Plan for {{current_file_path}}: {{diff}}"},
+ },
+ },
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Review {{current_file_path}} with plan {{plan_guidance}}: {{diff}}"},
+ },
+ },
+ },
+ MainToolDefs: []llm.ToolDef{
+ {Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
+ },
+ })
+ a.currentDate = "2025-06-26 10:00"
+ a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}}
+
+ err := a.executeSubtask(context.Background(), a.diffs[0])
+ if err != nil {
+ t.Fatalf("executeSubtask: %v", err)
+ }
+}
+
+func TestExecuteSubtask_ContextCancelled(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
+ },
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
+ if err == nil {
+ t.Fatal("expected error for cancelled context")
+ }
+}
+
+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{Content: &filterResp}}},
+ Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 2},
+ }},
+ }
+
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
+
+ a := New(Args{
+ LLMClient: client,
+ Model: "test",
+ Session: sess,
+ CommentCollector: collector,
+ Template: template.Template{
+ ReviewFilterTask: &template.LlmConversation{
+ Timeout: 30,
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
+ },
+ MaxTokens: 10000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
+ },
+ })
+
+ a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
+
+ comments := collector.CommentsForPath("a.go")
+ if len(comments) != 1 {
+ t.Errorf("expected 1 comment unchanged, got %d", len(comments))
+ }
+}
+
+func TestDispatchSubtasks_AllFilteredBySize(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 10,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
+ },
+ })
+ a.diffs = []model.Diff{
+ {NewPath: "big.go", Diff: strings.Repeat("word ", 500), Insertions: 100},
+ }
+
+ _, err := a.dispatchSubtasks(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "all diffs filtered out") {
+ t.Errorf("expected 'all diffs filtered out' error, got: %v", err)
+ }
+}
+
+func TestDispatchSubtasks_AllFailed(t *testing.T) {
+ tmpDir := t.TempDir()
+ sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
+
+ a := New(Args{
+ LLMClient: &fakeAgentClient{},
+ Model: "test",
+ Session: sess,
+ Template: template.Template{
+ MaxTokens: 100000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{Messages: nil},
+ },
+ })
+ a.diffs = []model.Diff{
+ {NewPath: "a.go", Diff: "+x", Insertions: 1},
+ }
+ a.currentDate = "2025-06-26"
+
+ _, err := a.dispatchSubtasks(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "failed") {
+ t.Errorf("expected failure error, got: %v", err)
+ }
+}
diff --git a/internal/llm/embedded_loader_test.go b/internal/llm/embedded_loader_test.go
new file mode 100644
index 0000000..4ac3dbf
--- /dev/null
+++ b/internal/llm/embedded_loader_test.go
@@ -0,0 +1,78 @@
+package llm
+
+import (
+ "testing"
+)
+
+func TestParseBpeData_Valid(t *testing.T) {
+ // "hello" base64 = "aGVsbG8="
+ input := []byte("aGVsbG8= 42\nd29ybGQ= 7\n")
+ ranks, err := parseBpeData(input)
+ if err != nil {
+ t.Fatalf("parseBpeData: %v", err)
+ }
+ if ranks["hello"] != 42 {
+ t.Errorf("ranks[hello] = %d, want 42", ranks["hello"])
+ }
+ if ranks["world"] != 7 {
+ t.Errorf("ranks[world] = %d, want 7", ranks["world"])
+ }
+}
+
+func TestParseBpeData_EmptyLines(t *testing.T) {
+ input := []byte("\n \naGVsbG8= 1\n\n")
+ ranks, err := parseBpeData(input)
+ if err != nil {
+ t.Fatalf("parseBpeData: %v", err)
+ }
+ if len(ranks) != 1 {
+ t.Errorf("expected 1 entry, got %d", len(ranks))
+ }
+}
+
+func TestParseBpeData_InvalidLine(t *testing.T) {
+ input := []byte("nospacehere\n")
+ _, err := parseBpeData(input)
+ if err == nil {
+ t.Error("expected error for line without space")
+ }
+}
+
+func TestParseBpeData_InvalidBase64(t *testing.T) {
+ input := []byte("!!!invalid 1\n")
+ _, err := parseBpeData(input)
+ if err == nil {
+ t.Error("expected error for invalid base64")
+ }
+}
+
+func TestParseBpeData_InvalidRank(t *testing.T) {
+ input := []byte("aGVsbG8= notanumber\n")
+ _, err := parseBpeData(input)
+ if err == nil {
+ t.Error("expected error for non-integer rank")
+ }
+}
+
+func TestLoadTiktokenBpe_KnownURL(t *testing.T) {
+ loader := &embeddedBpeLoader{}
+ ranks, err := loader.LoadTiktokenBpe("https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken")
+ if err != nil {
+ t.Fatalf("LoadTiktokenBpe: %v", err)
+ }
+ if len(ranks) == 0 {
+ t.Error("expected non-empty ranks for cl100k_base")
+ }
+}
+
+func TestLoadTiktokenBpe_UnknownURL(t *testing.T) {
+ loader := &embeddedBpeLoader{}
+ _, err := loader.LoadTiktokenBpe("https://example.com/unknown.tiktoken")
+ if err == nil {
+ t.Error("expected error for unknown URL")
+ }
+}
+
+func TestInitEmbeddedLoader(t *testing.T) {
+ InitEmbeddedLoader()
+}
diff --git a/internal/llm/message_test.go b/internal/llm/message_test.go
new file mode 100644
index 0000000..910233c
--- /dev/null
+++ b/internal/llm/message_test.go
@@ -0,0 +1,326 @@
+package llm
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestNewTextMessage(t *testing.T) {
+ m := NewTextMessage("user", "hello")
+ if m.Role != "user" {
+ t.Errorf("Role = %q, want user", m.Role)
+ }
+ if m.Content != "hello" {
+ t.Errorf("Content = %v, want hello", m.Content)
+ }
+ if m.ToolCallID != "" {
+ t.Error("ToolCallID should be empty")
+ }
+ if len(m.ToolCalls) != 0 {
+ t.Errorf("ToolCalls should be nil, got %v", m.ToolCalls)
+ }
+}
+
+func TestNewToolCallMessage(t *testing.T) {
+ calls := []ToolCall{
+ {ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a", Arguments: `{}`}},
+ {ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b", Arguments: `{"x":1}`}},
+ }
+ m := NewToolCallMessage("thinking", calls)
+ if m.Role != "assistant" {
+ t.Errorf("Role = %q, want assistant", m.Role)
+ }
+ if m.Content != "thinking" {
+ t.Errorf("Content = %v, want thinking", m.Content)
+ }
+ if len(m.ToolCalls) != 2 {
+ t.Fatalf("ToolCalls len = %d, want 2", len(m.ToolCalls))
+ }
+ if m.ToolCalls[0].ID != "c1" || m.ToolCalls[1].Function.Name != "tool_b" {
+ t.Errorf("ToolCalls not copied correctly")
+ }
+
+ // Mutation of original must not affect the message.
+ calls[0].ID = "mutated"
+ if m.ToolCalls[0].ID == "mutated" {
+ t.Error("NewToolCallMessage must copy ToolCalls")
+ }
+}
+
+func TestNewToolCallMessage_NilCalls(t *testing.T) {
+ m := NewToolCallMessage("text", nil)
+ if m.ToolCalls != nil {
+ t.Errorf("expected nil ToolCalls for nil input, got %v", m.ToolCalls)
+ }
+}
+
+func TestNewToolResultMessage(t *testing.T) {
+ m := NewToolResultMessage("call-123", "result text")
+ if m.Role != "tool" {
+ t.Errorf("Role = %q, want tool", m.Role)
+ }
+ if m.Content != "result text" {
+ t.Errorf("Content = %v, want result text", m.Content)
+ }
+ if m.ToolCallID != "call-123" {
+ t.Errorf("ToolCallID = %q, want call-123", m.ToolCallID)
+ }
+}
+
+func TestExtractText_String(t *testing.T) {
+ m := Message{Role: "user", Content: "plain text"}
+ if got := m.ExtractText(); got != "plain text" {
+ t.Errorf("ExtractText() = %q, want plain text", got)
+ }
+}
+
+func TestExtractText_ContentBlocks(t *testing.T) {
+ m := Message{Role: "assistant", Content: []ContentBlock{
+ {Type: "text", Text: "part1"},
+ {Type: "text", Text: " part2"},
+ }}
+ if got := m.ExtractText(); got != "part1 part2" {
+ t.Errorf("ExtractText() = %q, want 'part1 part2'", got)
+ }
+}
+
+func TestExtractText_NestedContentBlocks(t *testing.T) {
+ m := Message{Role: "tool", Content: []ContentBlock{
+ {
+ Type: "tool_result",
+ Content: []ContentBlock{
+ {Type: "text", Text: "inner1"},
+ {Type: "text", Text: "inner2"},
+ },
+ },
+ {Type: "text", Text: "outer"},
+ }}
+ got := m.ExtractText()
+ if got != "inner1inner2outer" {
+ t.Errorf("ExtractText() = %q, want inner1inner2outer", got)
+ }
+}
+
+func TestExtractText_Default(t *testing.T) {
+ m := Message{Role: "user", Content: 42}
+ if got := m.ExtractText(); got != "" {
+ t.Errorf("ExtractText() for non-string/non-block = %q, want empty", got)
+ }
+}
+
+func TestExtractText_NilContent(t *testing.T) {
+ m := Message{Role: "user", Content: nil}
+ if got := m.ExtractText(); got != "" {
+ t.Errorf("ExtractText() for nil = %q, want empty", got)
+ }
+}
+
+func TestChatResponse_Content(t *testing.T) {
+ text := "hello world"
+ resp := &ChatResponse{
+ Choices: []Choice{{
+ Message: ResponseMessage{Content: &text},
+ }},
+ }
+ if got := resp.Content(); got != "hello world" {
+ t.Errorf("Content() = %q, want hello world", got)
+ }
+}
+
+func TestChatResponse_Content_Empty(t *testing.T) {
+ resp := &ChatResponse{}
+ if got := resp.Content(); got != "" {
+ t.Errorf("Content() with no choices = %q, want empty", got)
+ }
+}
+
+func TestChatResponse_Content_FallbackToReasoning(t *testing.T) {
+ empty := ""
+ resp := &ChatResponse{
+ Choices: []Choice{{
+ Message: ResponseMessage{Content: &empty, ReasoningContent: "reasoning here"},
+ }},
+ }
+ if got := resp.Content(); got != "reasoning here" {
+ t.Errorf("Content() = %q, want reasoning here", got)
+ }
+}
+
+func TestChatResponse_Content_NilContent(t *testing.T) {
+ resp := &ChatResponse{
+ Choices: []Choice{{
+ Message: ResponseMessage{Content: nil, ReasoningContent: "fallback"},
+ }},
+ }
+ if got := resp.Content(); got != "fallback" {
+ t.Errorf("Content() = %q, want fallback", got)
+ }
+}
+
+func TestChatResponse_Content_StripsThinkTags(t *testing.T) {
+ text := "internalanswer"
+ resp := &ChatResponse{
+ Choices: []Choice{{
+ Message: ResponseMessage{Content: &text},
+ }},
+ }
+ if got := resp.Content(); got != "internalanswer" {
+ t.Errorf("Content() = %q, want internalanswer", got)
+ }
+}
+
+func TestChatResponse_ToolCalls(t *testing.T) {
+ resp := &ChatResponse{
+ Choices: []Choice{{
+ Message: ResponseMessage{
+ ToolCalls: []ToolCall{
+ {ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a"}},
+ {ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b"}},
+ },
+ },
+ }},
+ }
+ calls := resp.ToolCalls()
+ if len(calls) != 2 {
+ t.Fatalf("ToolCalls() len = %d, want 2", len(calls))
+ }
+ if calls[0].Function.Name != "tool_a" || calls[1].Function.Name != "tool_b" {
+ t.Error("ToolCalls returned unexpected values")
+ }
+}
+
+func TestChatResponse_ToolCalls_Empty(t *testing.T) {
+ resp := &ChatResponse{}
+ if got := resp.ToolCalls(); got != nil {
+ t.Errorf("ToolCalls() with no choices = %v, want nil", got)
+ }
+}
+
+func TestParseShellRC(t *testing.T) {
+ tmp := t.TempDir()
+ rcPath := filepath.Join(tmp, ".zshrc")
+
+ content := `# some comment
+export PATH="/usr/bin:$PATH"
+export ANTHROPIC_BASE_URL="https://api.example.com"
+export ANTHROPIC_AUTH_TOKEN='sk-test-token'
+export ANTHROPIC_MODEL=claude-sonnet-4-20250514
+`
+ if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ ep, ok, err := parseShellRC(rcPath, "")
+ if err != nil {
+ t.Fatalf("parseShellRC: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected ok=true")
+ }
+ if ep.Token != "sk-test-token" {
+ t.Errorf("Token = %q, want sk-test-token", ep.Token)
+ }
+ if ep.Model != "claude-sonnet-4-20250514" {
+ t.Errorf("Model = %q", ep.Model)
+ }
+ if ep.Protocol != "anthropic" {
+ t.Errorf("Protocol = %q, want anthropic", ep.Protocol)
+ }
+ if ep.AuthHeader != "authorization" {
+ t.Errorf("AuthHeader = %q, want authorization", ep.AuthHeader)
+ }
+ if ep.Source != "Shell rc file" {
+ t.Errorf("Source = %q", ep.Source)
+ }
+}
+
+func TestParseShellRC_ModelOverride(t *testing.T) {
+ tmp := t.TempDir()
+ rcPath := filepath.Join(tmp, ".bashrc")
+
+ content := `export ANTHROPIC_BASE_URL="https://api.example.com"
+export ANTHROPIC_AUTH_TOKEN="token"
+export ANTHROPIC_MODEL=claude-3-opus
+`
+ os.WriteFile(rcPath, []byte(content), 0644)
+
+ ep, ok, err := parseShellRC(rcPath, "override-model")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ok {
+ t.Fatal("expected ok=true")
+ }
+ if ep.Model != "override-model" {
+ t.Errorf("Model = %q, want override-model", ep.Model)
+ }
+}
+
+func TestParseShellRC_Incomplete(t *testing.T) {
+ tmp := t.TempDir()
+ rcPath := filepath.Join(tmp, ".zshrc")
+
+ content := `export ANTHROPIC_BASE_URL="https://api.example.com"
+export ANTHROPIC_AUTH_TOKEN="token"
+# missing ANTHROPIC_MODEL
+`
+ os.WriteFile(rcPath, []byte(content), 0644)
+
+ _, ok, err := parseShellRC(rcPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok {
+ t.Error("expected ok=false when model is missing")
+ }
+}
+
+func TestParseShellRC_NonexistentFile(t *testing.T) {
+ _, ok, err := parseShellRC("/nonexistent/path/.zshrc", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ok {
+ t.Error("expected ok=false for missing file")
+ }
+}
+
+func TestModelListContains(t *testing.T) {
+ models := []string{"gpt-4", " claude-3-opus ", "gemini-pro"}
+ if !modelListContains(models, "claude-3-opus") {
+ t.Error("expected true for claude-3-opus")
+ }
+ if !modelListContains(models, "gpt-4") {
+ t.Error("expected true for gpt-4")
+ }
+ if modelListContains(models, "gpt-3.5") {
+ t.Error("expected false for gpt-3.5")
+ }
+ if modelListContains(nil, "anything") {
+ t.Error("expected false for nil list")
+ }
+}
+
+func TestDefaultAuthHeader(t *testing.T) {
+ if got := defaultAuthHeader("anthropic"); got != "authorization" {
+ t.Errorf("anthropic: got %q, want authorization", got)
+ }
+ if got := defaultAuthHeader("openai"); got != "" {
+ t.Errorf("openai: got %q, want empty", got)
+ }
+ if got := defaultAuthHeader(""); got != "" {
+ t.Errorf("empty: got %q, want empty", got)
+ }
+}
+
+func TestUserAgent(t *testing.T) {
+ got := userAgent("anthropic")
+ if got != "open-code-review/dev | anthropic" {
+ t.Errorf("userAgent(anthropic) = %q", got)
+ }
+ got2 := userAgent("")
+ if got2 != "open-code-review/dev" {
+ t.Errorf("userAgent('') = %q", got2)
+ }
+}
diff --git a/internal/llmloop/runner_test.go b/internal/llmloop/runner_test.go
new file mode 100644
index 0000000..6866cfd
--- /dev/null
+++ b/internal/llmloop/runner_test.go
@@ -0,0 +1,471 @@
+package llmloop
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+type fakeLLMClient struct {
+ response *llm.ChatResponse
+ err error
+}
+
+func (f *fakeLLMClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
+ return f.response, f.err
+}
+
+func newTestRunner(client llm.LLMClient, tpl template.Template) *Runner {
+ sess := session.New(t_tempDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
+ collector := tool.NewCommentCollector()
+ return NewRunner(Deps{
+ LLMClient: client,
+ Model: "test-model",
+ Template: tpl,
+ CommentCollector: collector,
+ Session: sess,
+ })
+}
+
+var t_tempDir string
+
+func TestRecordWarning(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+ r.RecordWarning("error", "main.go", "something went wrong")
+ r.RecordWarning("warn", "lib.go", "not great")
+
+ warnings := r.Warnings()
+ if len(warnings) != 2 {
+ t.Fatalf("len = %d, want 2", len(warnings))
+ }
+ if warnings[0].Type != "error" || warnings[0].File != "main.go" {
+ t.Errorf("warning[0] = %+v", warnings[0])
+ }
+ if warnings[1].Message != "not great" {
+ t.Errorf("warning[1].Message = %q", warnings[1].Message)
+ }
+}
+
+func TestRecordToolCall(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+ r.recordToolCall("file_read")
+ r.recordToolCall("file_read")
+ r.recordToolCall("code_comment")
+
+ calls := r.ToolCalls()
+ if calls["file_read"] != 2 {
+ t.Errorf("file_read = %d, want 2", calls["file_read"])
+ }
+ if calls["code_comment"] != 1 {
+ t.Errorf("code_comment = %d, want 1", calls["code_comment"])
+ }
+}
+
+func TestRecordUsage(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+
+ r.RecordUsage(nil)
+ if r.TotalInputTokens() != 0 {
+ t.Error("nil usage should not change counters")
+ }
+
+ r.RecordUsage(&llm.UsageInfo{
+ PromptTokens: 100,
+ CompletionTokens: 50,
+ CacheReadTokens: 10,
+ CacheWriteTokens: 5,
+ })
+ if r.TotalInputTokens() != 100 {
+ t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens())
+ }
+ if r.TotalOutputTokens() != 50 {
+ t.Errorf("TotalOutputTokens = %d, want 50", r.TotalOutputTokens())
+ }
+ if r.TotalCacheReadTokens() != 10 {
+ t.Errorf("TotalCacheReadTokens = %d, want 10", r.TotalCacheReadTokens())
+ }
+ if r.TotalCacheWriteTokens() != 5 {
+ t.Errorf("TotalCacheWriteTokens = %d, want 5", r.TotalCacheWriteTokens())
+ }
+ if r.TotalTokensUsed() != 150 {
+ t.Errorf("TotalTokensUsed = %d, want 150", r.TotalTokensUsed())
+ }
+}
+
+func TestCollectPendingComments_NilPool(t *testing.T) {
+ t_tempDir = t.TempDir()
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "fix"})
+
+ r := NewRunner(Deps{
+ CommentCollector: collector,
+ CommentWorkerPool: nil,
+ })
+ comments := r.CollectPendingComments()
+ if len(comments) != 1 {
+ t.Fatalf("expected 1 comment, got %d", len(comments))
+ }
+ if comments[0].Path != "a.go" {
+ t.Errorf("comment.Path = %q", comments[0].Path)
+ }
+}
+
+func TestCancelPendingCompression_NilJob(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+ r.cancelPendingCompression()
+}
+
+func TestCancelPendingCompression_WithJob(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+
+ cancelled := false
+ job := &compressionJob{
+ done: make(chan struct{}),
+ cancel: func() { cancelled = true },
+ }
+ r.pendingJob = job
+ r.cancelPendingCompression()
+
+ if !cancelled {
+ t.Error("cancel was not called")
+ }
+ if r.pendingJob != nil {
+ t.Error("pendingJob should be nil after cancel")
+ }
+}
+
+func TestTryApplyPendingCompression_NilJob(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+ msgs := []llm.Message{msg("user", "hi")}
+ if r.tryApplyPendingCompression(&msgs) {
+ t.Error("expected false for nil job")
+ }
+}
+
+func TestTryApplyPendingCompression_NotDone(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+
+ job := &compressionJob{
+ done: make(chan struct{}),
+ cancel: func() {},
+ }
+ r.pendingJob = job
+
+ msgs := []llm.Message{msg("user", "hi")}
+ if r.tryApplyPendingCompression(&msgs) {
+ t.Error("expected false for non-completed job")
+ }
+}
+
+func TestTryApplyPendingCompression_Applied(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+
+ rebuilt := []llm.Message{msg("system", "sys"), msg("user", "compressed")}
+ job := &compressionJob{
+ done: make(chan struct{}),
+ cancel: func() {},
+ rebuilt: rebuilt,
+ snapshotLen: 3,
+ }
+ close(job.done)
+ r.pendingJob = job
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "orig"),
+ msg("assistant", "resp"),
+ msg("tool", "appended after snapshot"),
+ }
+ applied := r.tryApplyPendingCompression(&msgs)
+ if !applied {
+ t.Fatal("expected applied=true")
+ }
+ if len(msgs) != 3 {
+ t.Fatalf("len(msgs) = %d, want 3", len(msgs))
+ }
+ if msgs[1].ExtractText() != "compressed" {
+ t.Errorf("msgs[1] = %q, want compressed", msgs[1].ExtractText())
+ }
+ if msgs[2].ExtractText() != "appended after snapshot" {
+ t.Errorf("msgs[2] = %q, want appended after snapshot", msgs[2].ExtractText())
+ }
+ if r.pendingJob != nil {
+ t.Error("pendingJob should be nil after apply")
+ }
+}
+
+func TestTryApplyPendingCompression_NilRebuilt(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{})
+
+ job := &compressionJob{
+ done: make(chan struct{}),
+ cancel: func() {},
+ rebuilt: nil,
+ snapshotLen: 3,
+ }
+ close(job.done)
+ r.pendingJob = job
+
+ msgs := []llm.Message{msg("user", "hi")}
+ applied := r.tryApplyPendingCompression(&msgs)
+ if applied {
+ t.Error("expected false when rebuilt is nil (compression failed)")
+ }
+ if r.pendingJob != nil {
+ t.Error("pendingJob should be nil even on non-apply")
+ }
+}
+
+func TestPartitionMessages_CompressionNeeded(t *testing.T) {
+ messages := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ for i := 0; i < 20; i++ {
+ messages = append(messages, msg("assistant", strings.Repeat("word ", 200)))
+ messages = append(messages, msg("tool", strings.Repeat("data ", 100)))
+ }
+
+ result := partitionMessages(messages, 500, 0)
+
+ if result.frozenEnd != 2 {
+ t.Errorf("frozenEnd = %d, want 2", result.frozenEnd)
+ }
+ if result.activeCount == 0 {
+ t.Error("activeCount should be > 0 for compression-needed case")
+ }
+ if result.compressEnd >= len(messages) {
+ t.Errorf("compressEnd = %d, should be < %d", result.compressEnd, len(messages))
+ }
+ if result.compressEnd <= result.frozenEnd {
+ t.Errorf("compressEnd (%d) should be > frozenEnd (%d)", result.compressEnd, result.frozenEnd)
+ }
+}
+
+func TestRunCompression_EmptyTemplate(t *testing.T) {
+ t_tempDir = t.TempDir()
+ r := newTestRunner(&fakeLLMClient{}, template.Template{
+ MaxTokens: 1000,
+ })
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ msg("assistant", "resp"),
+ }
+ got, err := r.runCompression(context.Background(), msgs, "test.go")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != 2 {
+ t.Errorf("expected 2 (frozen only), got %d", len(got))
+ }
+}
+
+func TestRunCompression_ShortMessages(t *testing.T) {
+ t_tempDir = t.TempDir()
+ tpl := template.Template{
+ MemoryCompressionTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
+ },
+ MaxTokens: 1000,
+ }
+ r := newTestRunner(&fakeLLMClient{}, tpl)
+
+ msgs := []llm.Message{msg("system", "sys"), msg("user", "prompt")}
+ got, err := r.runCompression(context.Background(), msgs, "test.go")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != 2 {
+ t.Errorf("expected 2, got %d", len(got))
+ }
+}
+
+func TestRunCompression_Success(t *testing.T) {
+ t_tempDir = t.TempDir()
+ summaryText := "compressed summary"
+ client := &fakeLLMClient{
+ response: &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{Content: &summaryText},
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 20},
+ },
+ }
+ tpl := template.Template{
+ MemoryCompressionTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "Summarize: {{context}}"}},
+ },
+ MaxTokens: 50,
+ }
+ r := newTestRunner(client, tpl)
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
+ msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
+ }
+
+ got, err := r.runCompression(context.Background(), msgs, "test.go")
+ if err != nil {
+ t.Fatalf("runCompression: %v", err)
+ }
+ if len(got) < 2 {
+ t.Fatalf("expected at least 2 messages, got %d", len(got))
+ }
+ if !strings.Contains(got[1].ExtractText(), "previous_review_summary") {
+ t.Errorf("expected summary in rebuilt messages, got: %s", got[1].ExtractText())
+ }
+ if r.TotalInputTokens() != 100 {
+ t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens())
+ }
+}
+
+func TestRunCompression_LLMError(t *testing.T) {
+ t_tempDir = t.TempDir()
+ client := &fakeLLMClient{
+ err: context.DeadlineExceeded,
+ }
+ tpl := template.Template{
+ MemoryCompressionTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
+ },
+ MaxTokens: 50,
+ }
+ r := newTestRunner(client, tpl)
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
+ msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
+ }
+
+ got, err := r.runCompression(context.Background(), msgs, "test.go")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if len(got) != len(msgs) {
+ t.Errorf("expected messages unchanged on error, got %d vs %d", len(got), len(msgs))
+ }
+}
+
+func TestRunCompression_EmptySummary(t *testing.T) {
+ t_tempDir = t.TempDir()
+ emptyStr := ""
+ client := &fakeLLMClient{
+ response: &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{Content: &emptyStr},
+ }},
+ },
+ }
+ tpl := template.Template{
+ MemoryCompressionTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
+ },
+ MaxTokens: 50,
+ }
+ r := newTestRunner(client, tpl)
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
+ msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
+ }
+
+ got, err := r.runCompression(context.Background(), msgs, "test.go")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != len(msgs) {
+ t.Errorf("expected messages unchanged on empty summary, got %d vs %d", len(got), len(msgs))
+ }
+}
+
+func TestTriggerAsyncCompression(t *testing.T) {
+ t_tempDir = t.TempDir()
+ summaryText := "async summary"
+ client := &fakeLLMClient{
+ response: &llm.ChatResponse{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{Content: &summaryText},
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 10},
+ },
+ }
+ tpl := template.Template{
+ MemoryCompressionTask: template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
+ },
+ MaxTokens: 50,
+ }
+ r := newTestRunner(client, tpl)
+
+ msgs := []llm.Message{
+ msg("system", "sys"),
+ msg("user", "prompt"),
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
+ msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
+ }
+
+ r.triggerAsyncCompression(context.Background(), msgs, "test.go")
+
+ r.compressionMu.Lock()
+ job := r.pendingJob
+ r.compressionMu.Unlock()
+
+ if job == nil {
+ t.Fatal("expected pendingJob to be set")
+ }
+ <-job.done
+
+ if job.rebuilt == nil {
+ t.Fatal("expected rebuilt to be set after completion")
+ }
+}
+
+func TestStripMarkdownFences_AdditionalCases(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"markdown fence", "```markdown\ncontent\n```", "content"},
+ {"xml fence", "```xml\n\n```", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := StripMarkdownFences(tt.input)
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scan/coverage_test.go b/internal/scan/coverage_test.go
new file mode 100644
index 0000000..9c4a1e2
--- /dev/null
+++ b/internal/scan/coverage_test.go
@@ -0,0 +1,703 @@
+package scan
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/rules"
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+// fakeScanClient is a minimal LLM client for scan tests that returns
+// pre-configured responses in sequence.
+type fakeScanClient struct {
+ responses []*llm.ChatResponse
+ idx int
+}
+
+func (f *fakeScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
+ if f.idx >= len(f.responses) {
+ empty := ""
+ return &llm.ChatResponse{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &empty}}},
+ Usage: &llm.UsageInfo{},
+ }, nil
+ }
+ resp := f.responses[f.idx]
+ f.idx++
+ return resp, nil
+}
+
+// errorScanClient always returns an error.
+type errorScanClient struct {
+ err error
+}
+
+func (e *errorScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
+ return nil, e.err
+}
+
+func TestAgent_Getters(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ a := newAgentForTest(t, tpl)
+ a.items = []model.ScanItem{
+ {Path: "a.go", Content: "package a", LineCount: 1},
+ {Path: "b.go", Content: "package b", LineCount: 1},
+ }
+
+ if a.ProjectSummary() != "" {
+ t.Errorf("ProjectSummary() should be empty, got %q", a.ProjectSummary())
+ }
+ if a.Session() == nil {
+ t.Error("Session() should not be nil")
+ }
+ if a.FilesReviewed() != 2 {
+ t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed())
+ }
+ diffs := a.Diffs()
+ if len(diffs) != 2 {
+ t.Fatalf("Diffs() len = %d, want 2", len(diffs))
+ }
+ if diffs[0].NewPath != "a.go" || diffs[1].NewPath != "b.go" {
+ t.Errorf("Diffs paths wrong: %q, %q", diffs[0].NewPath, diffs[1].NewPath)
+ }
+ if a.TotalTokensUsed() != 0 {
+ t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed())
+ }
+ if len(a.ToolCalls()) != 0 {
+ t.Errorf("ToolCalls() should be empty")
+ }
+}
+
+func TestLookupDiff(t *testing.T) {
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ a.items = []model.ScanItem{
+ {Path: "main.go", Content: "package main\n", LineCount: 1},
+ {Path: "lib.go", Content: "package lib\n", LineCount: 1},
+ }
+
+ d := a.lookupDiff("main.go")
+ if d == nil {
+ t.Fatal("expected non-nil for existing path")
+ }
+ if d.NewPath != "main.go" {
+ t.Errorf("NewPath = %q, want main.go", d.NewPath)
+ }
+ if d.NewFileContent != "package main\n" {
+ t.Errorf("NewFileContent = %q", d.NewFileContent)
+ }
+
+ if d2 := a.lookupDiff("nonexist.go"); d2 != nil {
+ t.Errorf("expected nil for missing path, got %+v", d2)
+ }
+}
+
+func TestFilterScanItems(t *testing.T) {
+ a := NewAgent(Args{
+ Template: makeTemplateWithFullScan(),
+ FileFilter: &rules.FileFilter{
+ Exclude: []string{"vendor/**"},
+ },
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ items := []model.ScanItem{
+ {Path: "main.go", Content: "package main\n", LineCount: 1},
+ {Path: "image.png", Content: "", IsBinary: true},
+ {Path: "vendor/dep.go", Content: "package dep\n", LineCount: 1},
+ {Path: "handler.go", Content: "package h\n", LineCount: 1},
+ }
+
+ kept := a.filterScanItems(items)
+ if len(kept) != 2 {
+ t.Fatalf("expected 2 kept, got %d", len(kept))
+ }
+ for _, it := range kept {
+ if it.Path == "image.png" || it.Path == "vendor/dep.go" {
+ t.Errorf("should not keep %s", it.Path)
+ }
+ }
+}
+
+func TestWhyExcluded_AllBranches(t *testing.T) {
+ tests := []struct {
+ name string
+ item model.ScanItem
+ filter *rules.FileFilter
+ want model.ExcludeReason
+ }{
+ {
+ name: "binary",
+ item: model.ScanItem{Path: "img.png", IsBinary: true},
+ want: model.ExcludeBinary,
+ },
+ {
+ name: "user exclude",
+ item: model.ScanItem{Path: "vendor/dep.go", Content: "x"},
+ filter: &rules.FileFilter{Exclude: []string{"vendor/**"}},
+ want: model.ExcludeUserRule,
+ },
+ {
+ name: "unsupported extension",
+ item: model.ScanItem{Path: "data.xyz123"},
+ want: model.ExcludeExtension,
+ },
+ {
+ name: "user include match passes",
+ item: model.ScanItem{Path: "src/main.go", Content: "x"},
+ filter: &rules.FileFilter{Include: []string{"src/**"}},
+ want: model.ExcludeNone,
+ },
+ {
+ name: "default excluded path",
+ item: model.ScanItem{Path: "pkg/handler_test.go", Content: "x"},
+ want: model.ExcludeDefaultPath,
+ },
+ {
+ name: "allowed file passes",
+ item: model.ScanItem{Path: "main.go", Content: "x"},
+ want: model.ExcludeNone,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ a := NewAgent(Args{
+ Template: makeTemplateWithFullScan(),
+ FileFilter: tt.filter,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ got := a.whyExcluded(tt.item)
+ if got != tt.want {
+ t.Errorf("whyExcluded(%q) = %q, want %q", tt.item.Path, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtFromPath(t *testing.T) {
+ tests := []struct {
+ path string
+ want string
+ }{
+ {"main.go", ".go"},
+ {"src/lib/utils.ts", ".ts"},
+ {"Makefile", ""},
+ {".gitignore", ""},
+ {"path/to/FILE.Go", ".go"},
+ {"a/b/c.Test.JS", ".js"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ got := extFromPath(tt.path)
+ if got != tt.want {
+ t.Errorf("extFromPath(%q) = %q, want %q", tt.path, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMaybeRunPlan_Success(t *testing.T) {
+ planJSON := `{"summary":"check error handling","checkpoints":[{"focus":"nil check","lines":"10-20","why":"potential NPE"}]}`
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}},
+ Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 50},
+ }},
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.PlanTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Plan for {{current_file_path}}: {{file_content}}"},
+ },
+ }
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ a.currentDate = "2026-06-26 10:00"
+
+ it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() {}\n"}
+ guidance := a.maybeRunPlan(context.Background(), it, "rule-text")
+
+ if !strings.Contains(guidance, "nil check") {
+ t.Errorf("guidance missing checkpoint, got: %q", guidance)
+ }
+ if !strings.Contains(guidance, "check error handling") {
+ t.Errorf("guidance missing summary, got: %q", guidance)
+ }
+ if a.TotalTokensUsed() != 150 {
+ t.Errorf("TotalTokensUsed() = %d, want 150", a.TotalTokensUsed())
+ }
+}
+
+func TestMaybeRunProjectSummary_Success(t *testing.T) {
+ summaryText := "Overall the code has good error handling but lacks input validation."
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summaryText}}},
+ Usage: &llm.UsageInfo{PromptTokens: 200, CompletionTokens: 80},
+ }},
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.ProjectSummaryTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Summarize {{comment_count}} comments across {{file_count}} files:\n{{all_comments}}"},
+ },
+ }
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ comments := []model.LlmComment{
+ {Path: "a.go", Content: "missing error check"},
+ {Path: "b.go", Content: "no input validation"},
+ }
+
+ a.maybeRunProjectSummary(context.Background(), comments)
+
+ if a.ProjectSummary() != summaryText {
+ t.Errorf("ProjectSummary() = %q, want %q", a.ProjectSummary(), summaryText)
+ }
+}
+
+func TestMaybeRunProjectSummary_SkipWhenDisabled(t *testing.T) {
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ a.maybeRunProjectSummary(context.Background(), []model.LlmComment{{Path: "a.go", Content: "x"}})
+ if a.ProjectSummary() != "" {
+ t.Error("summary should be empty when template has no ProjectSummaryTask")
+ }
+}
+
+func TestMaybeRunProjectSummary_SkipWhenNoComments(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ tpl.ProjectSummaryTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{all_comments}}"}},
+ }
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: &fakeScanClient{},
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ a.maybeRunProjectSummary(context.Background(), nil)
+ if a.ProjectSummary() != "" {
+ t.Error("summary should be empty when no comments")
+ }
+}
+
+func TestMaybeRunDedup_Success(t *testing.T) {
+ dedupResp := `{"groups":[{"members":["c-0","c-1"],"merged_content":"combined finding"},{"members":["c-2"]}]}`
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &dedupResp}}},
+ Usage: &llm.UsageInfo{PromptTokens: 80, CompletionTokens: 30},
+ }},
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.DedupTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Dedup: {{batch_comments}}"},
+ },
+ }
+
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 1"})
+ collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 2"})
+ collector.Add(model.LlmComment{Path: "b.go", Content: "unique finding"})
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: collector,
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ batchStart := 0
+ a.maybeRunDedup(context.Background(), 0, batchStart)
+
+ comments := collector.Comments()
+ if len(comments) != 2 {
+ t.Fatalf("expected 2 deduped comments, got %d", len(comments))
+ }
+ if comments[0].Content != "combined finding" {
+ t.Errorf("merged comment content = %q, want 'combined finding'", comments[0].Content)
+ }
+ if comments[1].Content != "unique finding" {
+ t.Errorf("second comment = %q, want 'unique finding'", comments[1].Content)
+ }
+}
+
+func TestMaybeRunDedup_SkipWhenDisabled(t *testing.T) {
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "c1"})
+ collector.Add(model.LlmComment{Path: "a.go", Content: "c2"})
+ collector.Add(model.LlmComment{Path: "a.go", Content: "c3"})
+
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ a.args.CommentCollector = collector
+ a.maybeRunDedup(context.Background(), 0, 0)
+
+ if len(collector.Comments()) != 3 {
+ t.Errorf("comments should be unchanged when dedup is disabled")
+ }
+}
+
+func TestMaybeRunDedup_SkipWhenTooFewComments(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ tpl.DedupTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "{{batch_comments}}"}},
+ }
+ tpl.DedupMinComments = 5
+
+ collector := tool.NewCommentCollector()
+ collector.Add(model.LlmComment{Path: "a.go", Content: "only one"})
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: &fakeScanClient{},
+ Model: "test",
+ CommentCollector: collector,
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ a.maybeRunDedup(context.Background(), 0, 0)
+ if len(collector.Comments()) != 1 {
+ t.Error("comments should be unchanged when below min threshold")
+ }
+}
+
+func TestExecuteSubtask_Success(t *testing.T) {
+ doneContent := ""
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &doneContent,
+ ToolCalls: []llm.ToolCall{{
+ ID: "c1", Type: "function",
+ Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
+ }},
+ },
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 20},
+ }},
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 100000
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ SkipPlan: true,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ a.currentDate = "2026-06-26 10:00"
+
+ it := model.ScanItem{Path: "main.go", Content: "package main\n", LineCount: 1}
+ err := a.executeSubtask(context.Background(), it)
+ if err != nil {
+ t.Fatalf("executeSubtask: %v", err)
+ }
+ if a.TotalTokensUsed() != 70 {
+ t.Errorf("TotalTokensUsed() = %d, want 70", a.TotalTokensUsed())
+ }
+}
+
+func TestExecuteSubtask_WithPlan(t *testing.T) {
+ planJSON := `{"summary":"focus on error paths","checkpoints":[]}`
+ doneContent := ""
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{
+ {
+ Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}},
+ Usage: &llm.UsageInfo{PromptTokens: 30, CompletionTokens: 20},
+ },
+ {
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &doneContent,
+ ToolCalls: []llm.ToolCall{{
+ ID: "c1", Type: "function",
+ Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
+ }},
+ },
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 60, CompletionTokens: 30},
+ },
+ },
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 100000
+ tpl.PlanTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "user", Content: "Plan {{current_file_path}}: {{file_content}}"},
+ },
+ }
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ a.currentDate = "2026-06-26 10:00"
+
+ it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() error { return nil }\n", LineCount: 2}
+ err := a.executeSubtask(context.Background(), it)
+ if err != nil {
+ t.Fatalf("executeSubtask: %v", err)
+ }
+}
+
+func TestExecuteSubtask_ContextCancelled(t *testing.T) {
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ a.currentDate = "2026-06-26"
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := a.executeSubtask(ctx, model.ScanItem{Path: "a.go", Content: "x", LineCount: 1})
+ if err == nil {
+ t.Fatal("expected error for cancelled context")
+ }
+}
+
+func TestRun_EmptyTemplate(t *testing.T) {
+ a := NewAgent(Args{
+ Template: template.ScanTemplate{},
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ _, err := a.Run(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "MAIN_TASK is missing") {
+ t.Errorf("expected MAIN_TASK error, got: %v", err)
+ }
+}
+
+func TestRun_NoReviewableFiles(t *testing.T) {
+ repo := initTestRepo(t)
+ writeFile(t, repo, "img.png", []byte{0x89, 0x50, 0x4e, 0x47})
+ gitCommit(t, repo, "binary")
+
+ a := NewAgent(Args{
+ RepoDir: repo,
+ Template: makeTemplateWithFullScan(),
+ LLMClient: &fakeScanClient{},
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ SkipPlan: true,
+ SkipDedup: true,
+ SkipSummary: true,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ comments, err := a.Run(context.Background())
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(comments) != 0 {
+ t.Errorf("expected 0 comments for binary-only repo, got %d", len(comments))
+ }
+}
+
+func TestRun_FullPipeline(t *testing.T) {
+ repo := initTestRepo(t)
+ writeFile(t, repo, "main.go", []byte("package main\nfunc main() {}\n"))
+ gitCommit(t, repo, "init")
+
+ doneContent := ""
+ client := &fakeScanClient{
+ responses: []*llm.ChatResponse{{
+ Choices: []llm.Choice{{
+ Message: llm.ResponseMessage{
+ Content: &doneContent,
+ ToolCalls: []llm.ToolCall{{
+ ID: "c1", Type: "function",
+ Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
+ }},
+ },
+ }},
+ Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 50},
+ }},
+ }
+
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 100000
+
+ a := NewAgent(Args{
+ RepoDir: repo,
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ MaxConcurrency: 1,
+ SkipPlan: true,
+ SkipDedup: true,
+ SkipSummary: true,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+
+ comments, err := a.Run(context.Background())
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ _ = comments
+
+ if a.FilesReviewed() != 1 {
+ t.Errorf("FilesReviewed = %d, want 1", a.FilesReviewed())
+ }
+ if a.TotalTokensUsed() == 0 {
+ t.Error("expected non-zero tokens after run")
+ }
+}
+
+func TestDispatchSubtasks_AllFailed(t *testing.T) {
+ client := &errorScanClient{err: context.DeadlineExceeded}
+
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 100000
+
+ a := NewAgent(Args{
+ Template: tpl,
+ LLMClient: client,
+ Model: "test",
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ MaxConcurrency: 1,
+ SkipPlan: true,
+ SkipDedup: true,
+ SkipSummary: true,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ a.items = []model.ScanItem{{Path: "a.go", Content: "x", LineCount: 1}}
+ a.currentDate = "2026-06-26"
+ a.args.Tools.Freeze()
+
+ _, err := a.dispatchSubtasks(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "failed") {
+ t.Errorf("expected all-failed error, got: %v", err)
+ }
+}
+
+func TestPhaseEnabled(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ a := newAgentForTest(t, tpl)
+
+ if a.planEnabled() {
+ t.Error("planEnabled should be false without PlanTask")
+ }
+ if a.dedupEnabled() {
+ t.Error("dedupEnabled should be false without DedupTask")
+ }
+ if a.summaryEnabled() {
+ t.Error("summaryEnabled should be false without ProjectSummaryTask")
+ }
+
+ tpl.PlanTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "plan"}},
+ }
+ tpl.DedupTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "dedup"}},
+ }
+ tpl.ProjectSummaryTask = &template.LlmConversation{
+ Messages: []template.ChatMessage{{Role: "user", Content: "summary"}},
+ }
+
+ a2 := NewAgent(Args{
+ Template: tpl,
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ if !a2.planEnabled() {
+ t.Error("planEnabled should be true with PlanTask")
+ }
+ if !a2.dedupEnabled() {
+ t.Error("dedupEnabled should be true with DedupTask")
+ }
+ if !a2.summaryEnabled() {
+ t.Error("summaryEnabled should be true with ProjectSummaryTask")
+ }
+
+ a3 := NewAgent(Args{
+ Template: tpl,
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ SkipPlan: true,
+ SkipDedup: true,
+ SkipSummary: true,
+ Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+ if a3.planEnabled() {
+ t.Error("planEnabled should be false with SkipPlan")
+ }
+ if a3.dedupEnabled() {
+ t.Error("dedupEnabled should be false with SkipDedup")
+ }
+ if a3.summaryEnabled() {
+ t.Error("summaryEnabled should be false with SkipSummary")
+ }
+}
From 9f71753f6801bd365da0d57758d991b88dee0bf4 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 00:22:30 +0800
Subject: [PATCH 09/87] test: improve coverage for template and pathutil
packages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
template: 71.2% → 84.9% — add Validate error-branch tests for both
Template and ScanTemplate, ApplyLanguage coverage for optional scan
tasks (DedupTask, ProjectSummaryTask), nil-optional-task path, and
non-system message skip verification.
pathutil: add tests for non-existent path error, relative path
resolution, nested symlink traversal, and additional WithinBase
edge cases.
---
internal/config/template/template_test.go | 167 ++++++++++++++++++++++
internal/pathutil/path_test.go | 82 +++++++++++
2 files changed, 249 insertions(+)
diff --git a/internal/config/template/template_test.go b/internal/config/template/template_test.go
index 0de23ec..f2f8727 100644
--- a/internal/config/template/template_test.go
+++ b/internal/config/template/template_test.go
@@ -160,3 +160,170 @@ func TestApplyLanguage_DefaultEnglish(t *testing.T) {
t.Errorf("MainTask system message does not end with %q", suffix)
}
}
+
+func TestValidate_Template_Errors(t *testing.T) {
+ cases := []struct {
+ name string
+ tpl Template
+ wantErr string
+ }{
+ {
+ name: "zero MaxTokens",
+ tpl: Template{MaxTokens: 0, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
+ wantErr: "max_tokens must be positive",
+ },
+ {
+ name: "negative MaxTokens",
+ tpl: Template{MaxTokens: -1, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
+ wantErr: "max_tokens must be positive",
+ },
+ {
+ name: "zero MaxToolRequestTimes",
+ tpl: Template{MaxTokens: 100, MaxToolRequestTimes: 0, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
+ wantErr: "max_tool_request_times must be positive",
+ },
+ {
+ name: "empty MainTask messages",
+ tpl: Template{MaxTokens: 100, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: nil}},
+ wantErr: "main_task.messages must not be empty",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.tpl.Validate()
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("error %q does not contain %q", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidate_ScanTemplate(t *testing.T) {
+ valid := ScanTemplate{
+ MaxTokens: 100,
+ MaxToolRequestTimes: 1,
+ MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}},
+ }
+ if err := valid.Validate(); err != nil {
+ t.Fatalf("valid ScanTemplate.Validate() error: %v", err)
+ }
+
+ cases := []struct {
+ name string
+ tpl ScanTemplate
+ wantErr string
+ }{
+ {
+ name: "zero MaxTokens",
+ tpl: ScanTemplate{MaxTokens: 0, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
+ wantErr: "scan: max_tokens must be positive",
+ },
+ {
+ name: "zero MaxToolRequestTimes",
+ tpl: ScanTemplate{MaxTokens: 100, MaxToolRequestTimes: 0, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
+ wantErr: "scan: max_tool_request_times must be positive",
+ },
+ {
+ name: "empty MainTask messages",
+ tpl: ScanTemplate{MaxTokens: 100, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: nil}},
+ wantErr: "scan: main_task.messages must not be empty",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.tpl.Validate()
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("error %q does not contain %q", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestLoadScanDefault_Validate(t *testing.T) {
+ tpl, err := LoadScanDefault()
+ if err != nil {
+ t.Fatalf("LoadScanDefault: %v", err)
+ }
+ if err := tpl.Validate(); err != nil {
+ t.Errorf("loaded scan template should be valid: %v", err)
+ }
+}
+
+func TestApplyLanguage_ScanTemplate_AllOptionalTasks(t *testing.T) {
+ tpl, err := LoadScanDefault()
+ if err != nil {
+ t.Fatalf("LoadScanDefault: %v", err)
+ }
+ if tpl.DedupTask == nil {
+ t.Fatal("DedupTask should be present in default scan template")
+ }
+ if tpl.ProjectSummaryTask == nil {
+ t.Fatal("ProjectSummaryTask should be present in default scan template")
+ }
+
+ tpl.ApplyLanguage("Japanese")
+ suffix := "Always respond in Japanese."
+
+ check := func(name string, conv *LlmConversation) {
+ t.Helper()
+ if conv == nil {
+ return
+ }
+ for _, m := range conv.Messages {
+ if m.Role == "system" && !strings.Contains(m.Content, suffix) {
+ t.Errorf("%s system message missing language directive", name)
+ }
+ }
+ }
+ check("MainTask", &tpl.MainTask)
+ check("PlanTask", tpl.PlanTask)
+ check("DedupTask", tpl.DedupTask)
+ check("ProjectSummaryTask", tpl.ProjectSummaryTask)
+ check("MemoryCompressionTask", &tpl.MemoryCompressionTask)
+}
+
+func TestApplyLanguage_ScanTemplate_NilOptionalTasks(t *testing.T) {
+ tpl := &ScanTemplate{
+ MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "base"}}},
+ MemoryCompressionTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "compress"}}},
+ }
+ tpl.ApplyLanguage("Korean")
+ suffix := "Always respond in Korean."
+ if !strings.Contains(tpl.MainTask.Messages[0].Content, suffix) {
+ t.Error("MainTask should contain language directive")
+ }
+ if !strings.Contains(tpl.MemoryCompressionTask.Messages[0].Content, suffix) {
+ t.Error("MemoryCompressionTask should contain language directive")
+ }
+}
+
+func TestApplyLanguage_SkipsNonSystemMessages(t *testing.T) {
+ tpl := &Template{
+ MainTask: LlmConversation{Messages: []ChatMessage{
+ {Role: "system", Content: "sys"},
+ {Role: "user", Content: "usr"},
+ }},
+ MemoryCompressionTask: LlmConversation{Messages: []ChatMessage{
+ {Role: "system", Content: "sys"},
+ }},
+ }
+ tpl.ApplyLanguage("French")
+ if strings.Contains(tpl.MainTask.Messages[1].Content, "French") {
+ t.Error("user-role message should not get language directive")
+ }
+}
+
+func TestResolveLang(t *testing.T) {
+ if got := resolveLang(""); got != "English" {
+ t.Errorf("resolveLang(\"\") = %q, want \"English\"", got)
+ }
+ if got := resolveLang("German"); got != "German" {
+ t.Errorf("resolveLang(\"German\") = %q, want \"German\"", got)
+ }
+}
diff --git a/internal/pathutil/path_test.go b/internal/pathutil/path_test.go
index 1af2b9d..3664e75 100644
--- a/internal/pathutil/path_test.go
+++ b/internal/pathutil/path_test.go
@@ -53,3 +53,85 @@ func TestCanonicalPathResolvesSymlink(t *testing.T) {
t.Fatalf("CanonicalPath(%q) = %q, want %q", linkPath, got, want)
}
}
+
+func TestCanonicalPath_NonExistentPath(t *testing.T) {
+ _, err := CanonicalPath(filepath.Join(t.TempDir(), "does", "not", "exist"))
+ if err == nil {
+ t.Fatal("expected error for non-existent path, got nil")
+ }
+}
+
+func TestCanonicalPath_RelativePath(t *testing.T) {
+ dir := t.TempDir()
+ file := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatalf("create file: %v", err)
+ }
+
+ oldWd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("Getwd: %v", err)
+ }
+ t.Cleanup(func() { os.Chdir(oldWd) })
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+
+ got, err := CanonicalPath("test.txt")
+ if err != nil {
+ t.Fatalf("CanonicalPath: %v", err)
+ }
+ if !filepath.IsAbs(got) {
+ t.Errorf("result should be absolute, got %q", got)
+ }
+}
+
+func TestCanonicalPath_NestedSymlink(t *testing.T) {
+ realDir := t.TempDir()
+ realFile := filepath.Join(realDir, "file.txt")
+ if err := os.WriteFile(realFile, []byte("hello"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+
+ linkDir := t.TempDir()
+ link1 := filepath.Join(linkDir, "link1")
+ if err := os.Symlink(realDir, link1); err != nil {
+ t.Skipf("symlink not supported: %v", err)
+ }
+ link2 := filepath.Join(linkDir, "link2")
+ if err := os.Symlink(link1, link2); err != nil {
+ t.Skipf("nested symlink not supported: %v", err)
+ }
+
+ got, err := CanonicalPath(filepath.Join(link2, "file.txt"))
+ if err != nil {
+ t.Fatalf("CanonicalPath: %v", err)
+ }
+ want, _ := filepath.EvalSymlinks(realFile)
+ if got != want {
+ t.Errorf("CanonicalPath through nested symlinks = %q, want %q", got, want)
+ }
+}
+
+func TestWithinBase_AdditionalCases(t *testing.T) {
+ cases := []struct {
+ name string
+ base string
+ target string
+ want bool
+ }{
+ {name: "same path", base: "/a/b", target: "/a/b", want: true},
+ {name: "deep child", base: "/a/b", target: "/a/b/c/d/e/f", want: true},
+ {name: "double dotdot escape", base: "/a/b/c", target: "/a/b/c/../../x", want: false},
+ {name: "dotdot only", base: "/a/b", target: "/a", want: false},
+ {name: "root base with child", base: "/", target: "/anything", want: true},
+ {name: "empty relative after clean", base: "/a/b", target: "/a/b/./c", want: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := WithinBase(tc.base, tc.target); got != tc.want {
+ t.Errorf("WithinBase(%q, %q) = %v, want %v", tc.base, tc.target, got, tc.want)
+ }
+ })
+ }
+}
From be4b0b0aa92747787396889a737a20d860e4a3c3 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 00:34:12 +0800
Subject: [PATCH 10/87] test: improve coverage for viewer package from 35% to
92%
Add comprehensive tests for handler, server, and store modules including
LoadSession full parsing, template rendering, and error/edge-case paths.
---
internal/viewer/handler_test.go | 202 +++++++++++
internal/viewer/server_extra_test.go | 238 +++++++++++++
internal/viewer/store_load_test.go | 487 +++++++++++++++++++++++++++
3 files changed, 927 insertions(+)
create mode 100644 internal/viewer/handler_test.go
create mode 100644 internal/viewer/server_extra_test.go
create mode 100644 internal/viewer/store_load_test.go
diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go
new file mode 100644
index 0000000..3f01296
--- /dev/null
+++ b/internal/viewer/handler_test.go
@@ -0,0 +1,202 @@
+package viewer
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestHandleRepos_Success(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "test-repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "s1.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T10:00:00Z"}`)
+
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handleRepos(rr, req, root)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "test-repo") {
+ t.Errorf("response does not contain repo name")
+ }
+}
+
+func TestHandleRepos_EmptyRoot(t *testing.T) {
+ root := t.TempDir()
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handleRepos(rr, req, root)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "No session data found") {
+ t.Errorf("expected empty-state message in body")
+ }
+}
+
+func TestHandleRepos_NotFoundForNonRootPath(t *testing.T) {
+ root := t.TempDir()
+ req := httptest.NewRequest("GET", "/other", nil)
+ rr := httptest.NewRecorder()
+ handleRepos(rr, req, root)
+
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rr.Code)
+ }
+}
+
+func TestHandleRepos_UnreadableRoot(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handleRepos(rr, req, "/nonexistent/definitely/missing/root")
+
+ // DiscoverRepos returns nil for non-existent dirs (treated as empty)
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200 (empty)", rr.Code)
+ }
+}
+
+func TestHandleRepos_PermissionDenied(t *testing.T) {
+ root := t.TempDir()
+ // Create a directory that exists but cannot be read
+ badDir := filepath.Join(root, "unreadable")
+ if err := os.MkdirAll(badDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ // Create a .jsonl inside so it's a valid sessions dir
+ writeJSONL(t, filepath.Join(badDir, "s.jsonl"), `{"type":"session_start"}`)
+ // Remove read permission on root
+ if err := os.Chmod(root, 0000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(root, 0755) })
+
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handleRepos(rr, req, root)
+
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want 500", rr.Code)
+ }
+}
+
+func TestHandleSessions_Success(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "myrepo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "sess1.jsonl"),
+ `{"type":"session_start","timestamp":"2025-03-01T09:00:00Z","cwd":"/home/user/project","model":"gpt-4"}`,
+ `{"type":"session_end","duration_seconds":60,"files_reviewed":["a.go"]}`)
+
+ req := httptest.NewRequest("GET", "/r/myrepo", nil)
+ rr := httptest.NewRecorder()
+ handleSessions(rr, req, root, "myrepo")
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "project") {
+ t.Errorf("response does not contain repo display name derived from CWD")
+ }
+}
+
+func TestHandleSessions_NoCWD(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo2")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "s.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","model":"m"}`)
+
+ req := httptest.NewRequest("GET", "/r/repo2", nil)
+ rr := httptest.NewRecorder()
+ handleSessions(rr, req, root, "repo2")
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+}
+
+func TestHandleSessions_ErrorOnBadDir(t *testing.T) {
+ root := t.TempDir()
+ req := httptest.NewRequest("GET", "/r/missing", nil)
+ rr := httptest.NewRecorder()
+ handleSessions(rr, req, root, "missing")
+
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want 500", rr.Code)
+ }
+}
+
+func TestHandleSession_Success(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "abc123.jsonl"),
+ `{"type":"session_start","timestamp":"2025-06-01T10:00:00Z","cwd":"/my/proj","model":"claude"}`,
+ `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"LGTM"}`,
+ `{"type":"session_end","duration_seconds":30,"files_reviewed":["main.go"]}`)
+
+ req := httptest.NewRequest("GET", "/r/repo/abc123", nil)
+ rr := httptest.NewRecorder()
+ handleSession(rr, req, root, "repo", "abc123")
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "proj") {
+ t.Errorf("response does not contain derived display name")
+ }
+}
+
+func TestHandleSession_NotFound(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/r/repo/nonexistent", nil)
+ rr := httptest.NewRecorder()
+ handleSession(rr, req, root, "repo", "nonexistent")
+
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rr.Code)
+ }
+}
+
+func TestHandleSession_EmptyCWD(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "s.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","model":"m"}`,
+ `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`)
+
+ req := httptest.NewRequest("GET", "/r/repo/s", nil)
+ rr := httptest.NewRecorder()
+ handleSession(rr, req, root, "repo", "s")
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+}
diff --git a/internal/viewer/server_extra_test.go b/internal/viewer/server_extra_test.go
new file mode 100644
index 0000000..c4e8b63
--- /dev/null
+++ b/internal/viewer/server_extra_test.go
@@ -0,0 +1,238 @@
+package viewer
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestParseTemplate_ReposHTML(t *testing.T) {
+ tmpl, err := parseTemplate("repos.html")
+ if err != nil {
+ t.Fatalf("parseTemplate(repos.html) error: %v", err)
+ }
+ if tmpl == nil {
+ t.Fatal("parseTemplate returned nil template")
+ }
+}
+
+func TestParseTemplate_SessionsHTML(t *testing.T) {
+ tmpl, err := parseTemplate("sessions.html")
+ if err != nil {
+ t.Fatalf("parseTemplate(sessions.html) error: %v", err)
+ }
+ if tmpl == nil {
+ t.Fatal("parseTemplate returned nil template")
+ }
+}
+
+func TestParseTemplate_SessionHTML(t *testing.T) {
+ tmpl, err := parseTemplate("session.html")
+ if err != nil {
+ t.Fatalf("parseTemplate(session.html) error: %v", err)
+ }
+ if tmpl == nil {
+ t.Fatal("parseTemplate returned nil template")
+ }
+}
+
+func TestParseTemplate_NonExistent(t *testing.T) {
+ _, err := parseTemplate("nonexistent.html")
+ if err == nil {
+ t.Error("expected error for non-existent template")
+ }
+}
+
+func TestRenderTemplate_Success(t *testing.T) {
+ rr := httptest.NewRecorder()
+ renderTemplate(rr, "repos.html", map[string]any{
+ "Repos": []RepoInfo{},
+ })
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ ct := rr.Header().Get("Content-Type")
+ if ct != "text/html; charset=utf-8" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+ if !strings.Contains(rr.Body.String(), "No session data found") {
+ t.Errorf("expected empty repos message in rendered output")
+ }
+}
+
+func TestRenderTemplate_WithRepos(t *testing.T) {
+ rr := httptest.NewRecorder()
+ renderTemplate(rr, "repos.html", map[string]any{
+ "Repos": []RepoInfo{
+ {EncodedPath: "my-project", SessionCount: 3},
+ },
+ })
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "my-project") {
+ t.Errorf("expected repo name in rendered output")
+ }
+}
+
+func TestRenderTemplate_BadTemplate(t *testing.T) {
+ rr := httptest.NewRecorder()
+ renderTemplate(rr, "nonexistent.html", nil)
+
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want 500", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "template error") {
+ t.Errorf("expected template error message")
+ }
+}
+
+func TestRenderTemplate_Sessions(t *testing.T) {
+ rr := httptest.NewRecorder()
+ renderTemplate(rr, "sessions.html", sessionsData{
+ EncodedRepo: "test-repo",
+ RepoName: "MyProject",
+ Sessions: []SessionSummary{},
+ })
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "MyProject") {
+ t.Errorf("expected repo name in sessions template")
+ }
+}
+
+func TestRenderTemplate_SessionPage(t *testing.T) {
+ rr := httptest.NewRecorder()
+ vs := &ViewSession{
+ Summary: SessionSummary{
+ SessionID: "abc",
+ Model: "gpt-4",
+ CWD: "/test",
+ },
+ Files: []*FileGroup{
+ {
+ FilePath: "main.go",
+ Tasks: map[TaskType][]*TaskCard{
+ MainTask: {
+ {
+ RequestNo: 1,
+ ResponseContent: "looks good",
+ Model: "gpt-4",
+ PromptTokens: 100,
+ CompletionTokens: 50,
+ },
+ },
+ },
+ },
+ },
+ }
+ renderTemplate(rr, "session.html", sessionPageData{
+ EncodedRepo: "repo",
+ RepoName: "MyRepo",
+ Session: vs,
+ })
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rr.Code)
+ }
+}
+
+func TestRenderTemplate_ExecutionError(t *testing.T) {
+ rr := httptest.NewRecorder()
+ // Pass wrong data type to trigger template execution error
+ // repos.html expects .Repos to be rangeable; passing a string causes execution error
+ renderTemplate(rr, "repos.html", map[string]any{
+ "Repos": "not-a-slice",
+ })
+ // Template execution may partially write before failing, so we just check it didn't panic
+ // and that something was written (the header was set before Execute)
+ ct := rr.Header().Get("Content-Type")
+ if ct != "text/html; charset=utf-8" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+}
+
+func TestStaticFS(t *testing.T) {
+ sfs := staticFS()
+ if sfs == nil {
+ t.Fatal("staticFS() returned nil")
+ }
+ // Should be able to open style.css
+ f, err := sfs.Open("style.css")
+ if err != nil {
+ t.Fatalf("failed to open style.css from staticFS: %v", err)
+ }
+ f.Close()
+}
+
+func TestResolveAllowedHostsFromEnv(t *testing.T) {
+ t.Setenv(EnvAllowedHosts, "custom.host,other.host")
+ allowed := resolveAllowedHostsFromEnv("192.168.1.5:5483")
+
+ if _, ok := allowed["localhost"]; !ok {
+ t.Error("missing localhost")
+ }
+ if _, ok := allowed["192.168.1.5"]; !ok {
+ t.Error("missing bind host")
+ }
+ if _, ok := allowed["custom.host"]; !ok {
+ t.Error("missing custom.host from env")
+ }
+ if _, ok := allowed["other.host"]; !ok {
+ t.Error("missing other.host from env")
+ }
+}
+
+func TestBuildAllowedHosts_BracketedIPv6(t *testing.T) {
+ a := buildAllowedHosts("[fe80::1]", "")
+ if _, ok := a["fe80::1"]; !ok {
+ t.Errorf("bracketed IPv6 bind host not stripped: %v", a)
+ }
+}
+
+func TestResolveAllowedHostsFromEnv_NoEnv(t *testing.T) {
+ t.Setenv(EnvAllowedHosts, "")
+ allowed := resolveAllowedHostsFromEnv(":5483")
+
+ if len(allowed) != 3 {
+ t.Errorf("expected 3 default hosts, got %d: %v", len(allowed), allowed)
+ }
+}
+
+func TestTemplateFuncTaskTypeClass(t *testing.T) {
+ tmpl, err := parseTemplate("session.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify we can execute with task data that exercises taskTypeClass
+ rr := httptest.NewRecorder()
+ vs := &ViewSession{
+ Summary: SessionSummary{SessionID: "x", CWD: "/p"},
+ Files: []*FileGroup{
+ {
+ FilePath: "f.go",
+ Tasks: map[TaskType][]*TaskCard{
+ PlanTask: {{RequestNo: 1, ResponseContent: "plan"}},
+ MainTask: {{RequestNo: 2, ResponseContent: "main"}},
+ MemoryCompressionTask: {{RequestNo: 3, ResponseContent: "mem"}},
+ ReLocationTask: {{RequestNo: 4, ResponseContent: "reloc"}},
+ TaskType("custom"): {{RequestNo: 5, ResponseContent: "custom"}},
+ },
+ },
+ },
+ }
+ err = tmpl.Execute(rr, sessionPageData{
+ EncodedRepo: "r",
+ RepoName: "R",
+ Session: vs,
+ })
+ if err != nil {
+ t.Errorf("template execution with all task types: %v", err)
+ }
+}
diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go
new file mode 100644
index 0000000..45b3ef7
--- /dev/null
+++ b/internal/viewer/store_load_test.go
@@ -0,0 +1,487 @@
+package viewer
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSessionsRoot(t *testing.T) {
+ root, err := SessionsRoot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ home, _ := os.UserHomeDir()
+ expected := filepath.Join(home, ".opencodereview", "sessions")
+ if root != expected {
+ t.Errorf("SessionsRoot() = %q, want %q", root, expected)
+ }
+}
+
+func TestLoadSession_FullParse(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "sess1.jsonl"),
+ `{"type":"session_start","timestamp":"2025-06-10T08:00:00Z","cwd":"/home/dev/proj","gitBranch":"feat","model":"claude-3","reviewMode":"commit","diffFrom":"aaa","diffTo":"bbb","diffCommit":"ccc"}`,
+ `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1,"messages":[{"role":"user","content":"review this"}]}`,
+ `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"Code looks good","duration_ms":1500,"model":"claude-3","usage":{"prompt_tokens":100,"completion_tokens":50,"cache_read_tokens":10,"cache_write_tokens":5},"tool_calls":[{"name":"search","arguments":"query"}]}`,
+ `{"type":"tool_call","filePath":"main.go","taskType":"main_task","result":"found 3 results","ok":true,"duration_ms":20}`,
+ `{"type":"llm_request","filePath":"util.go","taskType":"plan_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"util.go","taskType":"plan_task","content":"planning","duration_ms":800,"model":"claude-3","usage":{"prompt_tokens":200,"completion_tokens":80,"cache_read_tokens":0,"cache_write_tokens":0}}`,
+ `{"type":"session_end","duration_seconds":120.5,"files_reviewed":["main.go","util.go"],"llm_failures":1}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "sess1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check summary
+ if vs.Summary.SessionID != "sess1" {
+ t.Errorf("SessionID = %q", vs.Summary.SessionID)
+ }
+ if vs.Summary.CWD != "/home/dev/proj" {
+ t.Errorf("CWD = %q", vs.Summary.CWD)
+ }
+ if vs.Summary.GitBranch != "feat" {
+ t.Errorf("GitBranch = %q", vs.Summary.GitBranch)
+ }
+ if vs.Summary.Model != "claude-3" {
+ t.Errorf("Model = %q", vs.Summary.Model)
+ }
+ if vs.Summary.ReviewMode != "commit" {
+ t.Errorf("ReviewMode = %q", vs.Summary.ReviewMode)
+ }
+ if vs.Summary.DiffFrom != "aaa" {
+ t.Errorf("DiffFrom = %q", vs.Summary.DiffFrom)
+ }
+ if vs.Summary.DiffTo != "bbb" {
+ t.Errorf("DiffTo = %q", vs.Summary.DiffTo)
+ }
+ if vs.Summary.DiffCommit != "ccc" {
+ t.Errorf("DiffCommit = %q", vs.Summary.DiffCommit)
+ }
+ if vs.Summary.DurationSec != 120.5 {
+ t.Errorf("DurationSec = %f", vs.Summary.DurationSec)
+ }
+ if vs.Summary.FileCount != 2 {
+ t.Errorf("FileCount = %d", vs.Summary.FileCount)
+ }
+ if vs.Summary.LLMFailures != 1 {
+ t.Errorf("LLMFailures = %d", vs.Summary.LLMFailures)
+ }
+
+ // Check files are sorted
+ if len(vs.Files) != 2 {
+ t.Fatalf("Files count = %d, want 2", len(vs.Files))
+ }
+ if vs.Files[0].FilePath != "main.go" {
+ t.Errorf("Files[0] = %q, want main.go", vs.Files[0].FilePath)
+ }
+ if vs.Files[1].FilePath != "util.go" {
+ t.Errorf("Files[1] = %q, want util.go", vs.Files[1].FilePath)
+ }
+
+ // Check main.go task card
+ mainCards := vs.Files[0].Tasks[MainTask]
+ if len(mainCards) != 1 {
+ t.Fatalf("main.go main_task cards = %d", len(mainCards))
+ }
+ card := mainCards[0]
+ if card.RequestNo != 1 {
+ t.Errorf("RequestNo = %d", card.RequestNo)
+ }
+ if card.ResponseContent != "Code looks good" {
+ t.Errorf("ResponseContent = %q", card.ResponseContent)
+ }
+ if card.DurationMs != 1500 {
+ t.Errorf("DurationMs = %d", card.DurationMs)
+ }
+ if card.Model != "claude-3" {
+ t.Errorf("Model = %q", card.Model)
+ }
+ if card.PromptTokens != 100 {
+ t.Errorf("PromptTokens = %d", card.PromptTokens)
+ }
+ if card.CompletionTokens != 50 {
+ t.Errorf("CompletionTokens = %d", card.CompletionTokens)
+ }
+ if card.CacheReadTokens != 10 {
+ t.Errorf("CacheReadTokens = %d", card.CacheReadTokens)
+ }
+ if card.CacheWriteTokens != 5 {
+ t.Errorf("CacheWriteTokens = %d", card.CacheWriteTokens)
+ }
+
+ // Check tool calls
+ if len(card.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls = %d", len(card.ToolCalls))
+ }
+ tc := card.ToolCalls[0]
+ if tc.Name != "search" {
+ t.Errorf("ToolCall.Name = %q", tc.Name)
+ }
+ if !tc.Ok {
+ t.Error("ToolCall.Ok = false, want true")
+ }
+ if tc.Result != "found 3 results" {
+ t.Errorf("ToolCall.Result = %q", tc.Result)
+ }
+ if tc.DurationMs != 20 {
+ t.Errorf("ToolCall.DurationMs = %d", tc.DurationMs)
+ }
+
+ // Check token usage
+ if vs.TokenUsage.TotalPromptTokens != 300 {
+ t.Errorf("TotalPromptTokens = %d, want 300", vs.TokenUsage.TotalPromptTokens)
+ }
+ if vs.TokenUsage.TotalCompletionTokens != 130 {
+ t.Errorf("TotalCompletionTokens = %d, want 130", vs.TokenUsage.TotalCompletionTokens)
+ }
+ if vs.TokenUsage.TotalCacheReadTokens != 10 {
+ t.Errorf("TotalCacheReadTokens = %d", vs.TokenUsage.TotalCacheReadTokens)
+ }
+ if vs.TokenUsage.TotalCacheWriteTokens != 5 {
+ t.Errorf("TotalCacheWriteTokens = %d", vs.TokenUsage.TotalCacheWriteTokens)
+ }
+ if vs.TokenUsage.RequestCount != 2 {
+ t.Errorf("RequestCount = %d, want 2", vs.TokenUsage.RequestCount)
+ }
+
+ // Check file token breakdown
+ if len(vs.TokenUsage.FileTokenBreakdown) != 2 {
+ t.Fatalf("FileTokenBreakdown count = %d", len(vs.TokenUsage.FileTokenBreakdown))
+ }
+ // Sorted by total tokens (descending), util.go (200+80=280) > main.go (100+50=150)
+ if vs.TokenUsage.FileTokenBreakdown[0].FilePath != "util.go" {
+ t.Errorf("top token file = %q, want util.go", vs.TokenUsage.FileTokenBreakdown[0].FilePath)
+ }
+}
+
+func TestLoadSession_MissingFile(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := LoadSession(root, "repo", "nonexistent")
+ if err == nil {
+ t.Error("expected error for missing session file")
+ }
+}
+
+func TestLoadSession_MalformedLines(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "bad.jsonl"),
+ `not json at all`,
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{broken json`,
+ `{"type":"session_end","duration_seconds":10,"files_reviewed":[],"llm_failures":0}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "bad")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if vs.Summary.CWD != "/x" {
+ t.Errorf("CWD = %q, want /x (should skip malformed lines)", vs.Summary.CWD)
+ }
+}
+
+func TestLoadSession_LLMError(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "errs.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_error","filePath":"a.go","taskType":"main_task","error":"rate limit exceeded","duration_ms":500}`,
+ `{"type":"session_end","duration_seconds":5,"files_reviewed":["a.go"],"llm_failures":1}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "errs")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cards := vs.Files[0].Tasks[MainTask]
+ if len(cards) != 1 {
+ t.Fatalf("cards count = %d", len(cards))
+ }
+ if cards[0].Error != "rate limit exceeded" {
+ t.Errorf("Error = %q", cards[0].Error)
+ }
+ if cards[0].DurationMs != 500 {
+ t.Errorf("DurationMs = %d", cards[0].DurationMs)
+ }
+}
+
+func TestLoadSession_ToolCallWithOkFalse(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "tc.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"result","duration_ms":100,"model":"m","usage":{"prompt_tokens":10,"completion_tokens":5},"tool_calls":[{"name":"search","arguments":"query"}]}`,
+ `{"type":"tool_call","filePath":"a.go","taskType":"main_task","result":"error: not found","ok":false,"duration_ms":50}`,
+ `{"type":"session_end","duration_seconds":1,"files_reviewed":["a.go"]}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "tc")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cards := vs.Files[0].Tasks[MainTask]
+ if len(cards) != 1 {
+ t.Fatalf("cards = %d", len(cards))
+ }
+ if len(cards[0].ToolCalls) != 1 {
+ t.Fatalf("tool calls = %d", len(cards[0].ToolCalls))
+ }
+ tc := cards[0].ToolCalls[0]
+ if tc.Ok {
+ t.Error("expected Ok=false")
+ }
+ if tc.Result != "error: not found" {
+ t.Errorf("Result = %q", tc.Result)
+ }
+ if tc.DurationMs != 50 {
+ t.Errorf("DurationMs = %d", tc.DurationMs)
+ }
+}
+
+func TestLoadSession_MultipleRequests(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "multi.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"first pass","duration_ms":100,"model":"m","usage":{"prompt_tokens":50,"completion_tokens":20}}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":2,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"second pass","duration_ms":200,"model":"m","usage":{"prompt_tokens":60,"completion_tokens":30}}`,
+ `{"type":"session_end","duration_seconds":10,"files_reviewed":["a.go"]}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "multi")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cards := vs.Files[0].Tasks[MainTask]
+ if len(cards) != 2 {
+ t.Fatalf("cards = %d, want 2", len(cards))
+ }
+ if cards[0].ResponseContent != "first pass" {
+ t.Errorf("cards[0] content = %q", cards[0].ResponseContent)
+ }
+ if cards[1].ResponseContent != "second pass" {
+ t.Errorf("cards[1] content = %q", cards[1].ResponseContent)
+ }
+ if vs.TokenUsage.RequestCount != 2 {
+ t.Errorf("RequestCount = %d", vs.TokenUsage.RequestCount)
+ }
+}
+
+func TestLoadSession_EmptyFile(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(repoDir, "empty.jsonl"))
+
+ vs, err := LoadSession(root, "repo", "empty")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs.Files) != 0 {
+ t.Errorf("Files = %d, want 0", len(vs.Files))
+ }
+}
+
+func TestLoadSession_ResponseWithoutRequest(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // llm_response for a file that has no prior request - should not panic
+ writeJSONL(t, filepath.Join(repoDir, "orphan.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_response","filePath":"unknown.go","taskType":"main_task","content":"orphan","duration_ms":100,"model":"m"}`,
+ `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "orphan")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // No file groups should be created for orphan responses (no request created the fileIndex entry)
+ if len(vs.Files) != 0 {
+ t.Errorf("Files = %d, want 0 (orphan response has no request)", len(vs.Files))
+ }
+}
+
+func TestLoadSession_LLMErrorWithoutRequest(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "orphanerr.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_error","filePath":"unknown.go","taskType":"main_task","error":"fail","duration_ms":10}`,
+ `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`,
+ )
+
+ // Should not panic
+ vs, err := LoadSession(root, "repo", "orphanerr")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs.Files) != 0 {
+ t.Errorf("Files = %d", len(vs.Files))
+ }
+}
+
+func TestLoadSession_ToolCallWithoutRequest(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "orphantc.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"tool_call","filePath":"unknown.go","taskType":"main_task","result":"x","ok":true}`,
+ `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`,
+ )
+
+ // Should not panic
+ vs, err := LoadSession(root, "repo", "orphantc")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs.Files) != 0 {
+ t.Errorf("Files = %d", len(vs.Files))
+ }
+}
+
+func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) {
+ root := t.TempDir()
+ badRepo := filepath.Join(root, "unreadable-repo")
+ if err := os.MkdirAll(badRepo, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONL(t, filepath.Join(badRepo, "s.jsonl"), `{"type":"session_start"}`)
+ // Remove read permission so os.ReadDir(repoDir) fails
+ if err := os.Chmod(badRepo, 0000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(badRepo, 0755) })
+
+ repos, err := DiscoverRepos(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Unreadable repo is skipped (continue)
+ if len(repos) != 0 {
+ t.Errorf("repos = %d, want 0 (unreadable dir skipped)", len(repos))
+ }
+}
+
+func TestListSessions_SkipsUnreadableFiles(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a valid session file
+ writeJSONL(t, filepath.Join(repoDir, "good.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`)
+
+ // Create an unreadable jsonl file
+ badPath := filepath.Join(repoDir, "bad.jsonl")
+ if err := os.WriteFile(badPath, []byte(`{"type":"session_start"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(badPath, 0000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(badPath, 0644) })
+
+ sessions, err := ListSessions(root, "repo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Should get 1 session (the good one), bad one is skipped
+ if len(sessions) != 1 {
+ t.Errorf("sessions = %d, want 1 (bad file skipped)", len(sessions))
+ }
+}
+
+func TestLoadSession_MultipleTaskTypes(t *testing.T) {
+ root := t.TempDir()
+ repoDir := filepath.Join(root, "repo")
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ writeJSONL(t, filepath.Join(repoDir, "tasks.jsonl"),
+ `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"plan_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"plan_task","content":"planning","duration_ms":100,"model":"m","usage":{"prompt_tokens":10,"completion_tokens":5}}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"reviewing","duration_ms":200,"model":"m","usage":{"prompt_tokens":20,"completion_tokens":10}}`,
+ `{"type":"llm_request","filePath":"a.go","taskType":"memory_compression_task","request_no":1,"messages":[]}`,
+ `{"type":"llm_response","filePath":"a.go","taskType":"memory_compression_task","content":"compressed","duration_ms":50,"model":"m","usage":{"prompt_tokens":5,"completion_tokens":3}}`,
+ `{"type":"session_end","duration_seconds":5,"files_reviewed":["a.go"]}`,
+ )
+
+ vs, err := LoadSession(root, "repo", "tasks")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(vs.Files) != 1 {
+ t.Fatalf("Files = %d", len(vs.Files))
+ }
+ fg := vs.Files[0]
+ if len(fg.Tasks) != 3 {
+ t.Errorf("task types = %d, want 3", len(fg.Tasks))
+ }
+ if len(fg.Tasks[PlanTask]) != 1 {
+ t.Errorf("plan_task cards = %d", len(fg.Tasks[PlanTask]))
+ }
+ if len(fg.Tasks[MainTask]) != 1 {
+ t.Errorf("main_task cards = %d", len(fg.Tasks[MainTask]))
+ }
+ if len(fg.Tasks[MemoryCompressionTask]) != 1 {
+ t.Errorf("memory_compression_task cards = %d", len(fg.Tasks[MemoryCompressionTask]))
+ }
+}
From 38fc691498d1786e83a26834510a1d02b4faf79f Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 00:42:41 +0800
Subject: [PATCH 11/87] fix: skip permission-based tests when running as root
in CI
---
internal/viewer/handler_test.go | 3 +++
internal/viewer/store_load_test.go | 6 ++++++
2 files changed, 9 insertions(+)
diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go
index 3f01296..c7e9179 100644
--- a/internal/viewer/handler_test.go
+++ b/internal/viewer/handler_test.go
@@ -67,6 +67,9 @@ func TestHandleRepos_UnreadableRoot(t *testing.T) {
}
func TestHandleRepos_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("permission checks are bypassed for root")
+ }
root := t.TempDir()
// Create a directory that exists but cannot be read
badDir := filepath.Join(root, "unreadable")
diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go
index 45b3ef7..3ed4a92 100644
--- a/internal/viewer/store_load_test.go
+++ b/internal/viewer/store_load_test.go
@@ -392,6 +392,9 @@ func TestLoadSession_ToolCallWithoutRequest(t *testing.T) {
}
func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("permission checks are bypassed for root")
+ }
root := t.TempDir()
badRepo := filepath.Join(root, "unreadable-repo")
if err := os.MkdirAll(badRepo, 0755); err != nil {
@@ -415,6 +418,9 @@ func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) {
}
func TestListSessions_SkipsUnreadableFiles(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("permission checks are bypassed for root")
+ }
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
From 9a11fc1302a3b1ef48db4ab95e65bc9c04df565f Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 01:47:48 +0800
Subject: [PATCH 12/87] test: improve coverage for cmd/opencodereview package
from 42% to 70%
Add comprehensive unit tests covering config dispatch, emit run result,
git helpers, provider commands, provider TUI pure functions and View
rendering, shared utilities, flags parsing, scan command flags, and
small file entry points (version, viewer, llm, rules commands).
---
cmd/opencodereview/config_cmd_test.go | 374 ++++++
cmd/opencodereview/config_dispatch_test.go | 30 +
cmd/opencodereview/emit_run_result_test.go | 177 +++
cmd/opencodereview/flags_test.go | 176 ++-
cmd/opencodereview/git_test.go | 153 +++
cmd/opencodereview/output_helpers_test.go | 234 ++++
cmd/opencodereview/provider_cmd_test.go | 205 +++
cmd/opencodereview/provider_tui_funcs_test.go | 1160 +++++++++++++++++
cmd/opencodereview/scan_cmd_test.go | 107 ++
cmd/opencodereview/shared_test.go | 127 ++
cmd/opencodereview/smallfiles_test.go | 248 ++++
11 files changed, 2990 insertions(+), 1 deletion(-)
create mode 100644 cmd/opencodereview/config_dispatch_test.go
create mode 100644 cmd/opencodereview/emit_run_result_test.go
create mode 100644 cmd/opencodereview/git_test.go
create mode 100644 cmd/opencodereview/provider_cmd_test.go
create mode 100644 cmd/opencodereview/provider_tui_funcs_test.go
create mode 100644 cmd/opencodereview/shared_test.go
create mode 100644 cmd/opencodereview/smallfiles_test.go
diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go
index 88f1f79..af1e43a 100644
--- a/cmd/opencodereview/config_cmd_test.go
+++ b/cmd/opencodereview/config_cmd_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "os"
"testing"
)
@@ -417,6 +418,379 @@ func TestUnsetInvalidKey(t *testing.T) {
}
}
+func TestMergeModelLists(t *testing.T) {
+ tests := []struct {
+ name string
+ lists [][]string
+ want []string
+ }{
+ {"empty", nil, nil},
+ {"single list", [][]string{{"a", "b"}}, []string{"a", "b"}},
+ {"merge with dedup", [][]string{{"a", "b"}, {"b", "c"}}, []string{"a", "b", "c"}},
+ {"three lists", [][]string{{"x"}, {"y"}, {"x", "z"}}, []string{"x", "y", "z"}},
+ {"empty strings filtered", [][]string{{"a", "", "b"}}, []string{"a", "b"}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := mergeModelLists(tc.lists...)
+ if len(got) != len(tc.want) {
+ t.Fatalf("mergeModelLists() = %v, want %v", got, tc.want)
+ }
+ for i := range tc.want {
+ if got[i] != tc.want[i] {
+ t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestEnsureTelemetry(t *testing.T) {
+ cfg := &Config{}
+ if cfg.Telemetry != nil {
+ t.Fatal("Telemetry should be nil initially")
+ }
+ cfg.ensureTelemetry()
+ if cfg.Telemetry == nil {
+ t.Fatal("Telemetry should be non-nil after ensureTelemetry()")
+ }
+ cfg.ensureTelemetry()
+ if cfg.Telemetry == nil {
+ t.Fatal("Telemetry should remain non-nil on second call")
+ }
+}
+
+func TestSetConfigValueLlmURL(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.url", "https://example.com/v1"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Llm.URL != "https://example.com/v1" {
+ t.Errorf("URL = %q", cfg.Llm.URL)
+ }
+}
+
+func TestSetConfigValueLlmAuthToken(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.auth_token", "tok-123"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Llm.AuthToken != "tok-123" {
+ t.Errorf("AuthToken = %q", cfg.Llm.AuthToken)
+ }
+}
+
+func TestSetConfigValueLlmModel(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.model", "my-model"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Llm.Model != "my-model" {
+ t.Errorf("Model = %q", cfg.Llm.Model)
+ }
+}
+
+func TestSetConfigValueLlmUseAnthropic(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.use_anthropic", "false"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != false {
+ t.Errorf("UseAnthropic = %v", cfg.Llm.UseAnthropic)
+ }
+}
+
+func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.use_anthropic", "notbool"); err == nil {
+ t.Fatal("expected error for invalid boolean")
+ }
+}
+
+func TestSetConfigValueLanguage(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "language", "English"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Language != "English" {
+ t.Errorf("Language = %q", cfg.Language)
+ }
+}
+
+func TestSetConfigValueTelemetryEnabled(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.enabled", "true"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Telemetry == nil || !cfg.Telemetry.Enabled {
+ t.Error("Telemetry.Enabled should be true")
+ }
+}
+
+func TestSetConfigValueTelemetryEnabledInvalid(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.enabled", "notbool"); err == nil {
+ t.Fatal("expected error for invalid boolean")
+ }
+}
+
+func TestSetConfigValueTelemetryExporter(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.exporter", "otlp"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Telemetry.Exporter != "otlp" {
+ t.Errorf("Exporter = %q", cfg.Telemetry.Exporter)
+ }
+}
+
+func TestSetConfigValueTelemetryOTLPEndpoint(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.otlp_endpoint", "localhost:4317"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Telemetry.OTLPEndpoint != "localhost:4317" {
+ t.Errorf("OTLPEndpoint = %q", cfg.Telemetry.OTLPEndpoint)
+ }
+}
+
+func TestSetConfigValueTelemetryContentLogging(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.content_logging", "true"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if !cfg.Telemetry.ContentLog {
+ t.Error("ContentLog should be true")
+ }
+}
+
+func TestSetConfigValueTelemetryContentLoggingInvalid(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "telemetry.content_logging", "notbool"); err == nil {
+ t.Fatal("expected error for invalid boolean")
+ }
+}
+
+func TestSetConfigValueLlmExtraBody(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.extra_body", `{"key":"val"}`); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Llm.ExtraBody == nil {
+ t.Fatal("ExtraBody should not be nil")
+ }
+ if cfg.Llm.ExtraBody["key"] != "val" {
+ t.Errorf("ExtraBody[\"key\"] = %v", cfg.Llm.ExtraBody["key"])
+ }
+}
+
+func TestSetConfigValueLlmExtraBodyInvalid(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "llm.extra_body", "not-json"); err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestSetConfigValueUnknownKey(t *testing.T) {
+ cfg := &Config{}
+ if err := setConfigValue(cfg, "unknown.key", "val"); err == nil {
+ t.Fatal("expected error for unknown key")
+ }
+}
+
+func TestSetConfigValueProviderClearsModel(t *testing.T) {
+ cfg := &Config{Provider: "old-provider", Model: "old-model"}
+ if err := setConfigValue(cfg, "provider", "new-provider"); err != nil {
+ t.Fatalf("setConfigValue: %v", err)
+ }
+ if cfg.Model != "" {
+ t.Errorf("Model should be cleared on provider change, got %q", cfg.Model)
+ }
+}
+
+func TestRunConfigUnset_InvalidKey(t *testing.T) {
+ if err := runConfigUnset("provider"); err == nil {
+ t.Fatal("expected error for non custom_providers key")
+ }
+ if err := runConfigUnset("custom_providers."); err == nil {
+ t.Fatal("expected error for empty provider name")
+ }
+}
+
+func TestRunConfig_EmptyArgs(t *testing.T) {
+ err := runConfig(nil)
+ if err != nil {
+ t.Fatalf("runConfig with nil args should print usage, got error: %v", err)
+ }
+}
+
+func TestRunConfig_ProviderWithArgs(t *testing.T) {
+ err := runConfig([]string{"provider", "extra"})
+ if err == nil {
+ t.Fatal("expected error when provider has args")
+ }
+}
+
+func TestRunConfig_ModelWithArgs(t *testing.T) {
+ err := runConfig([]string{"model", "extra"})
+ if err == nil {
+ t.Fatal("expected error when model has args")
+ }
+}
+
+func TestDeleteCustomProvider_NotFound(t *testing.T) {
+ cfg := &Config{}
+ _, err := deleteCustomProvider(cfg, "nonexistent")
+ if err == nil {
+ t.Fatal("expected error for nil CustomProviders")
+ }
+
+ cfg.CustomProviders = map[string]ProviderEntry{"other": {}}
+ _, err = deleteCustomProvider(cfg, "nonexistent")
+ if err == nil {
+ t.Fatal("expected error for missing provider")
+ }
+}
+
+func TestActiveModelForProvider(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *Config
+ provider string
+ entry ProviderEntry
+ want string
+ }{
+ {"entry model", nil, "p", ProviderEntry{Model: "m1"}, "m1"},
+ {"cfg model", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{}, "m2"},
+ {"entry takes precedence", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{Model: "m1"}, "m1"},
+ {"different provider", &Config{Provider: "other", Model: "m2"}, "p", ProviderEntry{}, ""},
+ {"no model", &Config{Provider: "p"}, "p", ProviderEntry{}, ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := activeModelForProvider(tc.cfg, tc.provider, tc.entry)
+ if got != tc.want {
+ t.Errorf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNormalizeModelList(t *testing.T) {
+ tests := []struct {
+ name string
+ models []string
+ want []string
+ }{
+ {"dedup", []string{"a", "b", "a"}, []string{"a", "b"}},
+ {"trim spaces", []string{" a ", " b "}, []string{"a", "b"}},
+ {"filter empty", []string{"a", "", "b"}, []string{"a", "b"}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := normalizeModelList(tc.models)
+ if len(got) != len(tc.want) {
+ t.Fatalf("got %v, want %v", got, tc.want)
+ }
+ for i := range tc.want {
+ if got[i] != tc.want[i] {
+ t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestParseModelListValue(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ want int
+ }{
+ {"empty", "", 0},
+ {"json array", `["a","b"]`, 2},
+ {"comma separated", "a,b,c", 3},
+ {"bracket unquoted", "[a,b]", 2},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseModelListValue(tc.value)
+ if err != nil {
+ t.Fatalf("parseModelListValue: %v", err)
+ }
+ if len(got) != tc.want {
+ t.Errorf("got %d models, want %d: %v", len(got), tc.want, got)
+ }
+ })
+ }
+}
+
+func TestResolveConfigPath_Default(t *testing.T) {
+ t.Setenv("OCR_CONFIG_PATH", "")
+ p, err := resolveConfigPath()
+ if err != nil {
+ t.Fatalf("resolveConfigPath: %v", err)
+ }
+ if p == "" {
+ t.Fatal("expected non-empty default config path")
+ }
+}
+
+func TestResolveConfigPath_Env(t *testing.T) {
+ t.Setenv("OCR_CONFIG_PATH", "/tmp/test-config.json")
+ p, err := resolveConfigPath()
+ if err != nil {
+ t.Fatalf("resolveConfigPath: %v", err)
+ }
+ if p != "/tmp/test-config.json" {
+ t.Errorf("path = %q, want /tmp/test-config.json", p)
+ }
+}
+
+func TestLoadOrCreateConfig_NewFile(t *testing.T) {
+ cfg, err := loadOrCreateConfig(t.TempDir() + "/nonexistent.json")
+ if err != nil {
+ t.Fatalf("loadOrCreateConfig: %v", err)
+ }
+ if cfg == nil {
+ t.Fatal("expected non-nil config")
+ }
+}
+
+func TestLoadOrCreateConfig_InvalidJSON(t *testing.T) {
+ dir := t.TempDir()
+ path := dir + "/bad.json"
+ if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := loadOrCreateConfig(path)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestLoadAppConfig_NotExist(t *testing.T) {
+ cfg, err := LoadAppConfig(t.TempDir() + "/none.json")
+ if err != nil {
+ t.Fatalf("LoadAppConfig: %v", err)
+ }
+ if cfg != nil {
+ t.Fatal("expected nil config for non-existent file")
+ }
+}
+
+func TestLoadAppConfig_InvalidJSON(t *testing.T) {
+ dir := t.TempDir()
+ path := dir + "/bad.json"
+ if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := LoadAppConfig(path)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
func TestEnsureModelInList(t *testing.T) {
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
diff --git a/cmd/opencodereview/config_dispatch_test.go b/cmd/opencodereview/config_dispatch_test.go
new file mode 100644
index 0000000..87f02b7
--- /dev/null
+++ b/cmd/opencodereview/config_dispatch_test.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRunConfig_UnknownSubcommand(t *testing.T) {
+ err := runConfig([]string{"delete", "foo"})
+ if err == nil {
+ t.Fatal("expected error for unknown subcommand")
+ }
+ if !strings.Contains(err.Error(), "unknown") {
+ t.Errorf("error = %q, expected to contain 'unknown'", err.Error())
+ }
+}
+
+func TestRunConfig_InvalidSetMissingValue(t *testing.T) {
+ err := runConfig([]string{"set", "provider"})
+ if err == nil {
+ t.Fatal("expected error for set without value")
+ }
+}
+
+func TestRunConfig_InvalidUnsetMissingKey(t *testing.T) {
+ err := runConfig([]string{"unset"})
+ if err == nil {
+ t.Fatal("expected error for unset without key")
+ }
+}
diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go
new file mode 100644
index 0000000..4082ca2
--- /dev/null
+++ b/cmd/opencodereview/emit_run_result_test.go
@@ -0,0 +1,177 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/agent"
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+type mockResultProvider struct {
+ diffs []model.Diff
+ filesReviewed int64
+ inputTokens int64
+ outputTokens int64
+ totalTokens int64
+ cacheReadTokens int64
+ cacheWriteTokens int64
+ warnings []agent.AgentWarning
+ projectSummary string
+ toolCalls map[string]int64
+}
+
+func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
+func (m *mockResultProvider) FilesReviewed() int64 { return m.filesReviewed }
+func (m *mockResultProvider) TotalInputTokens() int64 { return m.inputTokens }
+func (m *mockResultProvider) TotalOutputTokens() int64 { return m.outputTokens }
+func (m *mockResultProvider) TotalTokensUsed() int64 { return m.totalTokens }
+func (m *mockResultProvider) TotalCacheReadTokens() int64 { return m.cacheReadTokens }
+func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWriteTokens }
+func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings }
+func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary }
+func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls }
+
+func TestEmitRunResult_JSONNoFiles(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 0}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out.Status != "skipped" {
+ t.Errorf("status = %q, want skipped", out.Status)
+ }
+}
+
+func TestEmitRunResult_JSONWithComments(t *testing.T) {
+ ag := &mockResultProvider{
+ filesReviewed: 3,
+ inputTokens: 100,
+ outputTokens: 50,
+ totalTokens: 150,
+ warnings: []agent.AgentWarning{{Type: "info", Message: "note"}},
+ toolCalls: map[string]int64{"file_read": 2},
+ }
+ comments := []model.LlmComment{{Path: "main.go", Content: "fix", StartLine: 1, EndLine: 2}}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, comments, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(out.Comments) != 1 {
+ t.Errorf("expected 1 comment, got %d", len(out.Comments))
+ }
+ if out.Summary == nil || out.Summary.FilesReviewed != 3 {
+ t.Errorf("summary.FilesReviewed = %v", out.Summary)
+ }
+}
+
+func TestEmitRunResult_TextNoComments(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 2}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "Looks good to me") {
+ t.Errorf("expected 'Looks good to me', got %q", got)
+ }
+}
+
+func TestEmitRunResult_TextWithComments(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 1}
+ comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, comments, time.Now(), "text", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "a.go") {
+ t.Errorf("expected path, got %q", got)
+ }
+ if !strings.Contains(got, "rename") {
+ t.Errorf("expected comment content, got %q", got)
+ }
+}
+
+func TestEmitRunResult_TextWithProjectSummary(t *testing.T) {
+ ag := &mockResultProvider{
+ filesReviewed: 5,
+ projectSummary: "All tests pass, code quality is good.",
+ }
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "Project Summary") {
+ t.Errorf("expected 'Project Summary', got %q", got)
+ }
+ if !strings.Contains(got, "All tests pass") {
+ t.Errorf("expected summary content, got %q", got)
+ }
+}
+
+func TestEmitRunResult_AgentTextRestoresQuiet(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 1}
+ q := newQuietHandle("text", "agent")
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", q)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if q.fn != nil {
+ t.Error("expected quiet handle to be restored")
+ }
+ _ = got
+}
+
+func TestEmitRunResult_AgentJSONDoesNotRestore(t *testing.T) {
+ ag := &mockResultProvider{
+ filesReviewed: 1,
+ inputTokens: 10,
+ outputTokens: 5,
+ totalTokens: 15,
+ }
+ q := newQuietHandle("json", "agent")
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "agent", q)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ q.Restore()
+}
+
+func TestEmitRunResult_NilQuietHandle(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 1}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ _ = got
+}
diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go
index 617dfa4..15701ca 100644
--- a/cmd/opencodereview/flags_test.go
+++ b/cmd/opencodereview/flags_test.go
@@ -1,6 +1,9 @@
package main
-import "testing"
+import (
+ "testing"
+ "time"
+)
func TestParseReviewFlagsModelOverride(t *testing.T) {
opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"})
@@ -18,3 +21,174 @@ func TestParseReviewFlagsModelOverride(t *testing.T) {
t.Errorf("audience = %q, want %q", opts.audience, "human")
}
}
+
+func TestParseReviewFlags_InvalidAudience(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--audience", "robot"})
+ if err == nil {
+ t.Fatal("expected error for invalid audience")
+ }
+}
+
+func TestParseReviewFlags_NegativeMaxTools(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--max-tools", "-1"})
+ if err == nil {
+ t.Fatal("expected error for negative max-tools")
+ }
+}
+
+func TestParseReviewFlags_MaxToolsBelowMin(t *testing.T) {
+ opts, err := parseReviewFlags([]string{"--max-tools", "5"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.maxTools != 10 {
+ t.Errorf("maxTools = %d, want 10 (clamped to min)", opts.maxTools)
+ }
+}
+
+func TestParseReviewFlags_NegativeMaxGitProcs(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--max-git-procs", "-1"})
+ if err == nil {
+ t.Fatal("expected error for negative max-git-procs")
+ }
+}
+
+func TestParseReviewFlags_ConflictingModes(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--from", "main", "--to", "dev", "--commit", "abc"})
+ if err == nil {
+ t.Fatal("expected error for conflicting modes")
+ }
+}
+
+func TestParseReviewFlags_FromWithoutTo(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--from", "main"})
+ if err == nil {
+ t.Fatal("expected error for --from without --to")
+ }
+}
+
+func TestParseReviewFlags_ToWithoutFrom(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--to", "dev"})
+ if err == nil {
+ t.Fatal("expected error for --to without --from")
+ }
+}
+
+func TestParseReviewFlags_Help(t *testing.T) {
+ opts, err := parseReviewFlags([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.showHelp {
+ t.Error("expected showHelp=true")
+ }
+}
+
+func TestParseReviewFlags_ShortFlags(t *testing.T) {
+ opts, err := parseReviewFlags([]string{"-c", "abc123", "-f", "json", "-p"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.commit != "abc123" {
+ t.Errorf("commit = %q, want abc123", opts.commit)
+ }
+ if opts.outputFormat != "json" {
+ t.Errorf("outputFormat = %q, want json", opts.outputFormat)
+ }
+ if !opts.preview {
+ t.Error("expected preview=true")
+ }
+}
+
+func TestParseConfigArgs_Empty(t *testing.T) {
+ _, err := parseConfigArgs(nil)
+ if err == nil {
+ t.Fatal("expected error for empty args")
+ }
+}
+
+func TestParseConfigArgs_Set(t *testing.T) {
+ act, err := parseConfigArgs([]string{"set", "llm.model", "gpt-4"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if act.subCmd != "set" || act.key != "llm.model" || act.value != "gpt-4" {
+ t.Errorf("got %+v", act)
+ }
+}
+
+func TestParseConfigArgs_SetMissingValue(t *testing.T) {
+ _, err := parseConfigArgs([]string{"set", "llm.model"})
+ if err == nil {
+ t.Fatal("expected error for missing value")
+ }
+}
+
+func TestParseConfigArgs_Unset(t *testing.T) {
+ act, err := parseConfigArgs([]string{"unset", "custom_providers.foo"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if act.subCmd != "unset" || act.key != "custom_providers.foo" {
+ t.Errorf("got %+v", act)
+ }
+}
+
+func TestParseConfigArgs_UnsetMissingKey(t *testing.T) {
+ _, err := parseConfigArgs([]string{"unset"})
+ if err == nil {
+ t.Fatal("expected error for missing key")
+ }
+}
+
+func TestParseConfigArgs_UnknownSubCmd(t *testing.T) {
+ _, err := parseConfigArgs([]string{"delete", "foo"})
+ if err == nil {
+ t.Fatal("expected error for unknown subcommand")
+ }
+}
+
+func TestDurationVar(t *testing.T) {
+ fs := newOcrFlagSet("test")
+ var d time.Duration
+ fs.DurationVar(&d, "timeout", 5*time.Second, "max duration")
+ if err := fs.Parse([]string{"--timeout", "10s"}); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if d != 10*time.Second {
+ t.Errorf("d = %v, want 10s", d)
+ }
+}
+
+func TestPrintDefaults(t *testing.T) {
+ fs := newOcrFlagSet("test")
+ var s string
+ fs.StringVar(&s, "name", "default", "a name")
+ fs.PrintDefaults()
+}
+
+func TestExpandShortFlags(t *testing.T) {
+ m := map[string]string{"c": "commit", "f": "format"}
+ tests := []struct {
+ name string
+ args []string
+ want []string
+ }{
+ {"expands short", []string{"-c", "abc"}, []string{"--commit", "abc"}},
+ {"keeps long", []string{"--format", "json"}, []string{"--format", "json"}},
+ {"unknown short kept", []string{"-x", "val"}, []string{"-x", "val"}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := expandShortFlags(tc.args, m)
+ if len(got) != len(tc.want) {
+ t.Fatalf("got %v, want %v", got, tc.want)
+ }
+ for i := range tc.want {
+ if got[i] != tc.want[i] {
+ t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
diff --git a/cmd/opencodereview/git_test.go b/cmd/opencodereview/git_test.go
new file mode 100644
index 0000000..0630a4c
--- /dev/null
+++ b/cmd/opencodereview/git_test.go
@@ -0,0 +1,153 @@
+package main
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func initTestGitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ cmds := [][]string{
+ {"git", "-C", dir, "init"},
+ {"git", "-C", dir, "config", "user.email", "test@test.com"},
+ {"git", "-C", dir, "config", "user.name", "Test"},
+ }
+ for _, args := range cmds {
+ cmd := exec.Command(args[0], args[1:]...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git init: %v: %s", err, out)
+ }
+ }
+ f := filepath.Join(dir, "README.md")
+ os.WriteFile(f, []byte("hello"), 0o644)
+ cmd := exec.Command("git", "-C", dir, "add", ".")
+ cmd.CombinedOutput()
+ cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit")
+ cmd.CombinedOutput()
+ return dir
+}
+
+func TestRunGitCmd_Success(t *testing.T) {
+ dir := initTestGitRepo(t)
+ out, err := runGitCmd(dir, "rev-parse", "--git-dir")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(out) == 0 {
+ t.Error("expected non-empty output")
+ }
+}
+
+func TestRunGitCmd_Failure(t *testing.T) {
+ dir := t.TempDir()
+ _, err := runGitCmd(dir, "rev-parse", "--git-dir")
+ if err == nil {
+ t.Error("expected error for non-git dir")
+ }
+}
+
+func TestGetCommitMessage(t *testing.T) {
+ dir := initTestGitRepo(t)
+ msg, err := getCommitMessage(dir, "HEAD")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if msg != "initial commit" {
+ t.Errorf("msg = %q, want 'initial commit'", msg)
+ }
+}
+
+func TestGetCommitMessage_InvalidCommit(t *testing.T) {
+ dir := initTestGitRepo(t)
+ _, err := getCommitMessage(dir, "nonexistent-ref-xyz")
+ if err == nil {
+ t.Fatal("expected error for invalid commit")
+ }
+}
+
+func TestResolveRepoDir_ValidGitRepo(t *testing.T) {
+ dir := initTestGitRepo(t)
+ resolved, err := resolveRepoDir(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resolved == "" {
+ t.Error("expected non-empty resolved path")
+ }
+}
+
+func TestResolveRepoDir_NotGitRepo(t *testing.T) {
+ dir := t.TempDir()
+ _, err := resolveRepoDir(dir)
+ if err == nil {
+ t.Fatal("expected error for non-git dir")
+ }
+ if !strings.Contains(err.Error(), "not a git repository") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestResolveRepoDir_EmptyUsesWd(t *testing.T) {
+ dir := initTestGitRepo(t)
+ origDir, _ := os.Getwd()
+ defer os.Chdir(origDir)
+ os.Chdir(dir)
+
+ resolved, err := resolveRepoDir("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resolved == "" {
+ t.Error("expected non-empty resolved path")
+ }
+}
+
+func TestRequireGitRepo_Valid(t *testing.T) {
+ dir := initTestGitRepo(t)
+ if err := requireGitRepo(dir); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestRequireGitRepo_Invalid(t *testing.T) {
+ dir := t.TempDir()
+ err := requireGitRepo(dir)
+ if err == nil {
+ t.Fatal("expected error for non-git dir")
+ }
+}
+
+func TestValidateReviewRefs_ValidCommit(t *testing.T) {
+ dir := initTestGitRepo(t)
+ err := validateReviewRefs(dir, reviewOptions{commit: "HEAD"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateReviewRefs_InvalidCommit(t *testing.T) {
+ dir := initTestGitRepo(t)
+ err := validateReviewRefs(dir, reviewOptions{commit: "nonexistent-ref-xyz"})
+ if err == nil {
+ t.Fatal("expected error for invalid commit ref")
+ }
+}
+
+func TestValidateReviewRefs_EmptySkipped(t *testing.T) {
+ dir := initTestGitRepo(t)
+ err := validateReviewRefs(dir, reviewOptions{})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestBuildToolRegistry(t *testing.T) {
+ reg := buildToolRegistry(nil, nil)
+ if reg == nil {
+ t.Fatal("expected non-nil registry")
+ }
+}
diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go
index 74d8106..d0d25b2 100644
--- a/cmd/opencodereview/output_helpers_test.go
+++ b/cmd/opencodereview/output_helpers_test.go
@@ -162,6 +162,34 @@ func TestBuildDiffLines(t *testing.T) {
})
}
+func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
+ err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil)
+
+ w.Close()
+ os.Stdout = old
+
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+
+ var out jsonOutput
+ json.Unmarshal(buf.Bytes(), &out)
+ if out.Status != "completed_with_errors" {
+ t.Errorf("status = %q, want completed_with_errors", out.Status)
+ }
+ if !strings.Contains(out.Message, "errors") {
+ t.Errorf("message = %q, expected to mention errors", out.Message)
+ }
+}
+
func TestStatusBadge(t *testing.T) {
tests := []struct {
status string
@@ -326,3 +354,209 @@ func TestOutputJSONNoFiles(t *testing.T) {
t.Errorf("status = %q, want skipped", out.Status)
}
}
+
+func captureStdout(t *testing.T, fn func()) string {
+ t.Helper()
+ old := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe: %v", err)
+ }
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = old
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+ return buf.String()
+}
+
+func TestOutputText_NoComments(t *testing.T) {
+ got := captureStdout(t, func() {
+ outputText(nil)
+ })
+ if !strings.Contains(got, "Looks good to me") {
+ t.Errorf("expected 'Looks good to me', got %q", got)
+ }
+}
+
+func TestOutputText_WithComments(t *testing.T) {
+ comments := []model.LlmComment{
+ {Path: "main.go", StartLine: 10, EndLine: 15, Content: "potential nil dereference"},
+ }
+ got := captureStdout(t, func() {
+ outputText(comments)
+ })
+ if !strings.Contains(got, "main.go") {
+ t.Errorf("expected path in output, got %q", got)
+ }
+ if !strings.Contains(got, "potential nil dereference") {
+ t.Errorf("expected comment content in output, got %q", got)
+ }
+}
+
+func TestOutputTextWithWarnings_NoCommentsNoErrors(t *testing.T) {
+ warnings := []agent.AgentWarning{{Type: "warning", File: "x.go", Message: "slow"}}
+ got := captureStdout(t, func() {
+ outputTextWithWarnings(nil, warnings)
+ })
+ if !strings.Contains(got, "Looks good to me") {
+ t.Errorf("expected 'Looks good to me', got %q", got)
+ }
+}
+
+func TestOutputTextWithWarnings_NoCommentsWithSubtaskError(t *testing.T) {
+ warnings := []agent.AgentWarning{{Type: "subtask_error", File: "y.go", Message: "failed"}}
+ got := captureStdout(t, func() {
+ outputTextWithWarnings(nil, warnings)
+ })
+ if !strings.Contains(got, "could not be reviewed") {
+ t.Errorf("expected subtask error message, got %q", got)
+ }
+}
+
+func TestOutputTextWithWarnings_WithComments(t *testing.T) {
+ comments := []model.LlmComment{
+ {Path: "a.go", StartLine: 1, EndLine: 3, Content: "fix this"},
+ }
+ warnings := []agent.AgentWarning{{Type: "info", File: "b.go", Message: "note"}}
+ got := captureStdout(t, func() {
+ outputTextWithWarnings(comments, warnings)
+ })
+ if !strings.Contains(got, "a.go") {
+ t.Errorf("expected comment path, got %q", got)
+ }
+ if !strings.Contains(got, "fix this") {
+ t.Errorf("expected comment content, got %q", got)
+ }
+}
+
+func TestRenderComment_EmptyContentNoDiff(t *testing.T) {
+ got := captureStdout(t, func() {
+ renderComment(model.LlmComment{Path: "skip.go", StartLine: 1, EndLine: 1, Content: "", ExistingCode: "", SuggestionCode: ""})
+ })
+ if got != "" {
+ t.Errorf("expected empty output for empty comment, got %q", got)
+ }
+}
+
+func TestRenderComment_ContentOnly(t *testing.T) {
+ got := captureStdout(t, func() {
+ renderComment(model.LlmComment{Path: "file.go", StartLine: 5, EndLine: 10, Content: "consider renaming"})
+ })
+ if !strings.Contains(got, "file.go:5-10") {
+ t.Errorf("expected path:line range, got %q", got)
+ }
+ if !strings.Contains(got, "consider renaming") {
+ t.Errorf("expected content, got %q", got)
+ }
+}
+
+func TestRenderComment_WithDiff(t *testing.T) {
+ got := captureStdout(t, func() {
+ renderComment(model.LlmComment{
+ Path: "diff.go",
+ StartLine: 1,
+ EndLine: 2,
+ Content: "rename var",
+ ExistingCode: "old := 1\n",
+ SuggestionCode: "new := 1\n",
+ })
+ })
+ if !strings.Contains(got, "diff.go:1-2") {
+ t.Errorf("expected path:line range, got %q", got)
+ }
+ if !strings.Contains(got, "rename var") {
+ t.Errorf("expected content, got %q", got)
+ }
+}
+
+func TestPrintDiffLine(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ content string
+ }{
+ {"added", "+", "new line"},
+ {"deleted", "-", "old line"},
+ {"context", " ", "context line"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := captureStdout(t, func() {
+ printDiffLine(tc.prefix, tc.content, "\033[92m", "\033[48;2;0;60;0m")
+ })
+ if !strings.Contains(got, tc.prefix) {
+ t.Errorf("expected prefix %q in output, got %q", tc.prefix, got)
+ }
+ if !strings.Contains(got, tc.content) {
+ t.Errorf("expected content %q in output, got %q", tc.content, got)
+ }
+ })
+ }
+}
+
+func TestOutputPreviewText_NoFiles(t *testing.T) {
+ p := &agent.DiffPreview{TotalFiles: 0}
+ got := captureStdout(t, func() {
+ outputPreviewText(p)
+ })
+ if !strings.Contains(got, "No files changed") {
+ t.Errorf("expected 'No files changed', got %q", got)
+ }
+}
+
+func TestOutputPreviewText_WithReviewableFiles(t *testing.T) {
+ p := &agent.DiffPreview{
+ Entries: []agent.DiffPreviewEntry{
+ {Path: "main.go", Status: "modified", Insertions: 10, Deletions: 3, WillReview: true},
+ {Path: "util.go", Status: "added", Insertions: 20, Deletions: 0, WillReview: true},
+ },
+ TotalInsertions: 30,
+ TotalDeletions: 3,
+ TotalFiles: 2,
+ ReviewableCount: 2,
+ ExcludedCount: 0,
+ }
+ got := captureStdout(t, func() {
+ outputPreviewText(p)
+ })
+ if !strings.Contains(got, "2 file(s) changed") {
+ t.Errorf("expected file count, got %q", got)
+ }
+ if !strings.Contains(got, "Will review (2)") {
+ t.Errorf("expected 'Will review' section, got %q", got)
+ }
+ if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") {
+ t.Errorf("expected file paths, got %q", got)
+ }
+}
+
+func TestOutputPreviewText_WithExcludedFiles(t *testing.T) {
+ p := &agent.DiffPreview{
+ Entries: []agent.DiffPreviewEntry{
+ {Path: "src.go", Status: "modified", Insertions: 5, Deletions: 1, WillReview: true},
+ {Path: "vendor/lib.go", Status: "added", Insertions: 100, Deletions: 0, WillReview: false, ExcludeReason: model.ExcludeDefaultPath},
+ },
+ TotalInsertions: 105,
+ TotalDeletions: 1,
+ TotalFiles: 2,
+ ReviewableCount: 1,
+ ExcludedCount: 1,
+ }
+ got := captureStdout(t, func() {
+ outputPreviewText(p)
+ })
+ if !strings.Contains(got, "Will review (1)") {
+ t.Errorf("expected 'Will review (1)', got %q", got)
+ }
+ if !strings.Contains(got, "Excluded from review (1)") {
+ t.Errorf("expected 'Excluded from review (1)', got %q", got)
+ }
+ if !strings.Contains(got, "vendor/lib.go") {
+ t.Errorf("expected excluded file path, got %q", got)
+ }
+ if !strings.Contains(got, "default_path") {
+ t.Errorf("expected exclude reason, got %q", got)
+ }
+}
diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go
new file mode 100644
index 0000000..54dc906
--- /dev/null
+++ b/cmd/opencodereview/provider_cmd_test.go
@@ -0,0 +1,205 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMaskKey(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ want string
+ }{
+ {"empty", "", "(not set)"},
+ {"short", "abcd", "***"},
+ {"exactly 8", "12345678", "***"},
+ {"normal", "sk-ant-secret-key-1234", "sk-a***1234"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := maskKey(tc.key)
+ if got != tc.want {
+ t.Errorf("maskKey(%q) = %q, want %q", tc.key, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSaveConfig(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sub", "config.json")
+
+ cfg := &Config{
+ Provider: "anthropic",
+ Model: "claude-opus-4-6",
+ Language: "English",
+ }
+
+ if err := saveConfig(path, cfg); err != nil {
+ t.Fatalf("saveConfig: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if perm := info.Mode().Perm(); perm != 0o600 {
+ t.Errorf("perm = %o, want 600", perm)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ var loaded Config
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if loaded.Provider != "anthropic" {
+ t.Errorf("Provider = %q", loaded.Provider)
+ }
+ if loaded.Language != "English" {
+ t.Errorf("Language = %q", loaded.Language)
+ }
+}
+
+func TestApplyProviderDeletions(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+
+ cfg := &Config{
+ Provider: "keep",
+ CustomProviders: map[string]ProviderEntry{
+ "del1": {URL: "https://a.example.com"},
+ "del2": {URL: "https://b.example.com"},
+ "keep": {URL: "https://c.example.com"},
+ },
+ }
+ if err := saveConfig(configPath, cfg); err != nil {
+ t.Fatalf("saveConfig: %v", err)
+ }
+
+ clearedActive, err := applyProviderDeletions(configPath, cfg, []string{"del1", "del2"})
+ if err != nil {
+ t.Fatalf("applyProviderDeletions: %v", err)
+ }
+ if clearedActive {
+ t.Error("should not have cleared active provider")
+ }
+ if _, exists := cfg.CustomProviders["del1"]; exists {
+ t.Error("del1 should have been deleted")
+ }
+ if _, exists := cfg.CustomProviders["del2"]; exists {
+ t.Error("del2 should have been deleted")
+ }
+ if _, exists := cfg.CustomProviders["keep"]; !exists {
+ t.Error("keep should still exist")
+ }
+}
+
+func TestApplyProviderDeletions_ActiveCleared(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+
+ cfg := &Config{
+ Provider: "active-one",
+ CustomProviders: map[string]ProviderEntry{
+ "active-one": {URL: "https://x.example.com"},
+ },
+ }
+ if err := saveConfig(configPath, cfg); err != nil {
+ t.Fatalf("saveConfig: %v", err)
+ }
+
+ clearedActive, err := applyProviderDeletions(configPath, cfg, []string{"active-one"})
+ if err != nil {
+ t.Fatalf("applyProviderDeletions: %v", err)
+ }
+ if !clearedActive {
+ t.Error("should have cleared active provider")
+ }
+}
+
+func TestApplyProviderDeletions_SkipsNotFound(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "exists": {URL: "https://a.example.com"},
+ },
+ }
+ if err := saveConfig(configPath, cfg); err != nil {
+ t.Fatalf("saveConfig: %v", err)
+ }
+
+ _, err := applyProviderDeletions(configPath, cfg, []string{"nonexistent"})
+ if err != nil {
+ t.Fatalf("applyProviderDeletions should not fail, got: %v", err)
+ }
+}
+
+func TestRemoveModels(t *testing.T) {
+ tests := []struct {
+ name string
+ existing []string
+ remove []string
+ want []string
+ }{
+ {"remove one", []string{"a", "b", "c"}, []string{"b"}, []string{"a", "c"}},
+ {"remove none", []string{"a", "b"}, []string{"x"}, []string{"a", "b"}},
+ {"remove all", []string{"a", "b"}, []string{"a", "b"}, []string{}},
+ {"empty existing", nil, []string{"a"}, []string{}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := removeModels(tc.existing, tc.remove)
+ if len(got) != len(tc.want) {
+ t.Fatalf("removeModels() = %v, want %v", got, tc.want)
+ }
+ for i := range tc.want {
+ if got[i] != tc.want[i] {
+ t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestApplyManualConfig_MissingURL(t *testing.T) {
+ err := applyManualConfig("", &Config{}, providerTUIResult{url: "", model: "m"})
+ if err == nil {
+ t.Fatal("expected error for missing URL")
+ }
+}
+
+func TestApplyManualConfig_MissingModel(t *testing.T) {
+ err := applyManualConfig("", &Config{}, providerTUIResult{url: "https://example.com", model: ""})
+ if err == nil {
+ t.Fatal("expected error for missing model")
+ }
+}
+
+func TestApplyCustomProviderConfig_MissingProvider(t *testing.T) {
+ err := applyCustomProviderConfig("", &Config{}, providerTUIResult{provider: "", model: "m"})
+ if err == nil {
+ t.Fatal("expected error for missing provider")
+ }
+}
+
+func TestApplyCustomProviderConfig_MissingModel(t *testing.T) {
+ err := applyCustomProviderConfig("", &Config{}, providerTUIResult{provider: "p", model: ""})
+ if err == nil {
+ t.Fatal("expected error for missing model")
+ }
+}
+
+func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
+ err := applyOfficialProviderConfig("", &Config{}, providerTUIResult{provider: "", model: ""})
+ if err == nil {
+ t.Fatal("expected error for missing provider/model")
+ }
+}
diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go
new file mode 100644
index 0000000..a5f0001
--- /dev/null
+++ b/cmd/opencodereview/provider_tui_funcs_test.go
@@ -0,0 +1,1160 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
+)
+
+func TestCustomProviderActiveModel_NilCfg(t *testing.T) {
+ m := providerTUIModel{existingCfg: nil}
+ cp := customProviderListItem{name: "test", entry: ProviderEntry{Model: "m1"}}
+ got := m.customProviderActiveModel(cp)
+ if got != "" {
+ t.Errorf("expected empty string for nil cfg, got %q", got)
+ }
+}
+
+func TestCustomProviderActiveModel_DifferentProvider(t *testing.T) {
+ cfg := &Config{Provider: "other-provider"}
+ m := newProviderTUI(cfg, "")
+ cp := customProviderListItem{name: "test", entry: ProviderEntry{Model: "m1"}}
+ got := m.customProviderActiveModel(cp)
+ if got != "" {
+ t.Errorf("expected empty string for different provider, got %q", got)
+ }
+}
+
+func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) {
+ cfg := &Config{
+ Provider: "my-custom",
+ Model: "gpt-4",
+ CustomProviders: map[string]ProviderEntry{
+ "my-custom": {URL: "http://localhost", Model: "gpt-4"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ cp := customProviderListItem{name: "my-custom", entry: ProviderEntry{URL: "http://localhost"}}
+ got := m.customProviderActiveModel(cp)
+ if got != "gpt-4" {
+ t.Errorf("expected gpt-4, got %q", got)
+ }
+}
+
+func TestModelProviderName_OfficialTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ name := m.modelProviderName()
+ if name == "" {
+ t.Error("expected non-empty provider name")
+ }
+ providers := llm.ListProviders()
+ if len(providers) == 0 {
+ t.Skip("no providers registered")
+ }
+}
+
+func TestModelProviderName_CustomTab(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "my-llm": {URL: "http://localhost", Model: "m"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ name := m.modelProviderName()
+ if !strings.Contains(name, "(custom)") {
+ t.Errorf("expected '(custom)' in name, got %q", name)
+ }
+}
+
+func TestModelProviderName_CustomTab_NoSelection(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.customIdx = 999
+ name := m.modelProviderName()
+ if name != "" {
+ t.Errorf("expected empty fallback for out-of-bounds custom, got %q", name)
+ }
+}
+
+func TestModelCount(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ count := m.modelCount()
+ models := m.models()
+ if count != len(models)+1 {
+ t.Errorf("modelCount() = %d, want %d", count, len(models)+1)
+ }
+}
+
+func TestInit_ReturnsNil(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ cmd := m.Init()
+ if cmd != nil {
+ t.Error("Init() should return nil")
+ }
+}
+
+func TestListCursorPrefix_Active(t *testing.T) {
+ got := listCursorPrefix(true)
+ if !strings.Contains(got, tuiCursor) {
+ t.Errorf("expected cursor in prefix, got %q", got)
+ }
+}
+
+func TestListCursorPrefix_Inactive(t *testing.T) {
+ got := listCursorPrefix(false)
+ if strings.Contains(got, tuiCursor) {
+ t.Errorf("expected no cursor in prefix, got %q", got)
+ }
+}
+
+func TestRenderListName_Active(t *testing.T) {
+ got := renderListName("my-provider", true)
+ if !strings.Contains(got, "my-provider") {
+ t.Errorf("expected name in output, got %q", got)
+ }
+}
+
+func TestRenderListName_Inactive(t *testing.T) {
+ got := renderListName("my-provider", false)
+ if !strings.Contains(got, "my-provider") {
+ t.Errorf("expected name in output, got %q", got)
+ }
+}
+
+func TestCloneProviderEntry_WithExtraBody(t *testing.T) {
+ orig := ProviderEntry{
+ APIKey: "key",
+ URL: "http://localhost",
+ Protocol: "openai",
+ Model: "gpt-4",
+ Models: []string{"gpt-4", "gpt-3.5"},
+ AuthHeader: "Authorization",
+ ExtraBody: map[string]any{"temperature": 0.7, "stream": true},
+ }
+ clone := cloneProviderEntry(orig)
+
+ if clone.APIKey != orig.APIKey || clone.URL != orig.URL || clone.Protocol != orig.Protocol {
+ t.Error("basic fields not copied")
+ }
+ if len(clone.Models) != 2 || clone.Models[0] != "gpt-4" {
+ t.Errorf("Models not cloned: %v", clone.Models)
+ }
+ if clone.ExtraBody == nil {
+ t.Fatal("ExtraBody should not be nil")
+ }
+ if clone.ExtraBody["temperature"] != 0.7 {
+ t.Errorf("ExtraBody[temperature] = %v", clone.ExtraBody["temperature"])
+ }
+
+ clone.ExtraBody["new_key"] = "value"
+ if _, ok := orig.ExtraBody["new_key"]; ok {
+ t.Error("modifying clone should not affect original ExtraBody")
+ }
+
+ clone.Models = append(clone.Models, "gpt-5")
+ if len(orig.Models) != 2 {
+ t.Error("modifying clone should not affect original Models")
+ }
+}
+
+func TestCloneProviderEntry_NilExtraBody(t *testing.T) {
+ orig := ProviderEntry{
+ APIKey: "key",
+ URL: "http://localhost",
+ }
+ clone := cloneProviderEntry(orig)
+ if clone.ExtraBody != nil {
+ t.Error("ExtraBody should remain nil")
+ }
+}
+
+func TestCustomListCount(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "a": {URL: "http://a"},
+ "b": {URL: "http://b"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ got := m.customListCount()
+ if got != len(m.customProviders)+1 {
+ t.Errorf("customListCount() = %d, want %d", got, len(m.customProviders)+1)
+ }
+}
+
+func TestIsCustomModelItem(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ models := m.models()
+ if m.isCustomModelItem(len(models)) != true {
+ t.Error("expected true for custom model item index")
+ }
+ if len(models) > 0 && m.isCustomModelItem(0) {
+ t.Error("expected false for non-custom model index")
+ }
+}
+
+func upKey() tea.KeyPressMsg {
+ return tea.KeyPressMsg{Code: tea.KeyUp}
+}
+
+func TestHandleUp_OfficialTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ if len(m.providers) < 2 {
+ t.Skip("need at least 2 providers")
+ }
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.officialIdx != 1 {
+ t.Fatalf("after down, officialIdx = %d, want 1", m2.officialIdx)
+ }
+ result, _ = m2.Update(upKey())
+ m3 := result.(providerTUIModel)
+ if m3.officialIdx != 0 {
+ t.Errorf("after up, officialIdx = %d, want 0", m3.officialIdx)
+ }
+}
+
+func TestHandleUp_Wraps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ if len(m.providers) == 0 {
+ t.Skip("no providers")
+ }
+ result, _ := m.Update(upKey())
+ m2 := result.(providerTUIModel)
+ if m2.officialIdx != len(m2.providers)-1 {
+ t.Errorf("up from 0 should wrap to %d, got %d", len(m2.providers)-1, m2.officialIdx)
+ }
+}
+
+func TestHandleUp_CustomTab(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "a": {URL: "http://a"},
+ "b": {URL: "http://b"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 1
+ result, _ := m.Update(upKey())
+ m2 := result.(providerTUIModel)
+ if m2.customIdx != 0 {
+ t.Errorf("customIdx = %d, want 0", m2.customIdx)
+ }
+}
+
+func TestHandleUp_ModelStep(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.modelIdx = 1
+ result, _ := m.Update(upKey())
+ m2 := result.(providerTUIModel)
+ if m2.modelIdx != 0 {
+ t.Errorf("modelIdx = %d, want 0", m2.modelIdx)
+ }
+}
+
+func TestHandleUp_ModelStepWraps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.modelIdx = 0
+ result, _ := m.Update(upKey())
+ m2 := result.(providerTUIModel)
+ expected := m2.modelCount() - 1
+ if m2.modelIdx != expected {
+ t.Errorf("modelIdx = %d, want %d", m2.modelIdx, expected)
+ }
+}
+
+func TestBlurCPStep_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} {
+ m.cpStep = step
+ m.blurCPStep()
+ }
+}
+
+func TestFocusCPStep_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} {
+ m.cpStep = step
+ m.focusCPStep()
+ }
+}
+
+func TestCollectCustomProviders_Nil(t *testing.T) {
+ got := collectCustomProviders(nil)
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+}
+
+func TestCollectCustomProviders_NilMap(t *testing.T) {
+ got := collectCustomProviders(&Config{})
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+}
+
+func TestCollectCustomProviders_Sorted(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "zebra": {URL: "http://z"},
+ "alpha": {URL: "http://a"},
+ "mid": {URL: "http://m"},
+ },
+ }
+ got := collectCustomProviders(cfg)
+ if len(got) != 3 {
+ t.Fatalf("expected 3 items, got %d", len(got))
+ }
+ if got[0].name != "alpha" || got[1].name != "mid" || got[2].name != "zebra" {
+ t.Errorf("not sorted: %v, %v, %v", got[0].name, got[1].name, got[2].name)
+ }
+}
+
+func TestCustomProviderNameTaken(t *testing.T) {
+ m := providerTUIModel{existingCfg: nil}
+ if m.customProviderNameTaken("test") {
+ t.Error("nil cfg should return false")
+ }
+
+ m2 := newProviderTUI(&Config{}, "")
+ if m2.customProviderNameTaken("test") {
+ t.Error("nil CustomProviders should return false")
+ }
+
+ m3 := newProviderTUI(&Config{
+ CustomProviders: map[string]ProviderEntry{"test": {URL: "http://test"}},
+ }, "")
+ if !m3.customProviderNameTaken("test") {
+ t.Error("existing name should return true")
+ }
+ if m3.customProviderNameTaken("other") {
+ t.Error("non-existing name should return false")
+ }
+}
+
+func TestCurrentProvider_OutOfBounds(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.officialIdx = 9999
+ p := m.currentProvider()
+ if p.Name != "" {
+ t.Errorf("expected empty provider, got %q", p.Name)
+ }
+}
+
+func TestCurrentProvider_WrongTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ p := m.currentProvider()
+ if p.Name != "" {
+ t.Errorf("expected empty provider for non-official tab, got %q", p.Name)
+ }
+}
+
+func TestSelectedCustomProvider_NotCustomTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ _, ok := m.selectedCustomProvider()
+ if ok {
+ t.Error("expected false for non-custom tab")
+ }
+}
+
+func TestSelectedCustomProvider_OutOfBounds(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.customIdx = 9999
+ _, ok := m.selectedCustomProvider()
+ if ok {
+ t.Error("expected false for out-of-bounds index")
+ }
+}
+
+func TestWindowSizeMsg(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+ m2 := result.(providerTUIModel)
+ if m2.width != 120 || m2.height != 40 {
+ t.Errorf("size = %dx%d, want 120x40", m2.width, m2.height)
+ }
+}
+
+func TestBlurManualStep_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} {
+ m.manualStep = step
+ m.blurManualStep()
+ }
+}
+
+func TestFocusManualStep_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} {
+ m.manualStep = step
+ m.focusManualStep()
+ }
+}
+
+func TestHandleDown_OfficialTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ if len(m.providers) < 2 {
+ t.Skip("need at least 2 providers")
+ }
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.officialIdx != 1 {
+ t.Errorf("officialIdx = %d, want 1", m2.officialIdx)
+ }
+}
+
+func TestHandleDown_OfficialTab_Wraps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ if len(m.providers) == 0 {
+ t.Skip("no providers")
+ }
+ m.officialIdx = len(m.providers) - 1
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.officialIdx != 0 {
+ t.Errorf("down from last should wrap to 0, got %d", m2.officialIdx)
+ }
+}
+
+func TestHandleDown_CustomTab(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "a": {URL: "http://a"},
+ "b": {URL: "http://b"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.customIdx != 1 {
+ t.Errorf("customIdx = %d, want 1", m2.customIdx)
+ }
+}
+
+func TestHandleDown_CustomTab_Wraps(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "a": {URL: "http://a"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = m.customListCount() - 1
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.customIdx != 0 {
+ t.Errorf("down from last custom should wrap to 0, got %d", m2.customIdx)
+ }
+}
+
+func TestHandleDown_ModelStep(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.modelIdx = 0
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.modelIdx != 1 {
+ t.Errorf("modelIdx = %d, want 1", m2.modelIdx)
+ }
+}
+
+func TestHandleDown_ModelStep_Wraps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.modelIdx = m.modelCount() - 1
+ result, _ := m.Update(downKey())
+ m2 := result.(providerTUIModel)
+ if m2.modelIdx != 0 {
+ t.Errorf("modelIdx = %d, want 0", m2.modelIdx)
+ }
+}
+
+func TestCloneCustomProvidersMap(t *testing.T) {
+ src := map[string]ProviderEntry{
+ "a": {URL: "http://a", ExtraBody: map[string]any{"k": "v"}},
+ "b": {URL: "http://b"},
+ }
+ clone := cloneCustomProvidersMap(src)
+ if len(clone) != 2 {
+ t.Fatalf("expected 2, got %d", len(clone))
+ }
+ clone["a"] = ProviderEntry{URL: "http://changed"}
+ if src["a"].URL != "http://a" {
+ t.Error("modifying clone should not affect original")
+ }
+}
+
+func TestCloneCustomProvidersMap_Nil(t *testing.T) {
+ got := cloneCustomProvidersMap(nil)
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+}
+
+func TestCloneCustomProviderList(t *testing.T) {
+ src := []customProviderListItem{
+ {name: "a", entry: ProviderEntry{URL: "http://a"}},
+ {name: "b", entry: ProviderEntry{URL: "http://b"}},
+ }
+ clone := cloneCustomProviderList(src)
+ if len(clone) != 2 {
+ t.Fatalf("expected 2, got %d", len(clone))
+ }
+ clone[0].name = "changed"
+ if src[0].name != "a" {
+ t.Error("modifying clone should not affect original")
+ }
+}
+
+func TestCustomProviderEntry_FromConfig(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "test": {URL: "http://real", Model: "m1"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ fallback := ProviderEntry{URL: "http://fallback"}
+ got := m.customProviderEntry("test", fallback)
+ if got.URL != "http://real" {
+ t.Errorf("expected config entry, got URL %q", got.URL)
+ }
+}
+
+func TestCustomProviderEntry_Fallback(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ fallback := ProviderEntry{URL: "http://fallback"}
+ got := m.customProviderEntry("nonexist", fallback)
+ if got.URL != "http://fallback" {
+ t.Errorf("expected fallback, got URL %q", got.URL)
+ }
+}
+
+func TestNewModelTUI(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ if m.modelIdx != 0 {
+ t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx)
+ }
+ cmd := m.Init()
+ if cmd != nil {
+ t.Error("Init() should return nil")
+ }
+}
+
+func TestNewModelTUI_WithCurrentModel(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 || len(p[0].Models) == 0 {
+ t.Skip("need provider with models")
+ }
+ current := p[0].Models[0]
+ m := newModelTUI(p[0], current)
+ if m.modelIdx != 0 {
+ t.Errorf("modelIdx = %d, want 0 for first model", m.modelIdx)
+ }
+ if m.activeModel != current {
+ t.Errorf("activeModel = %q, want %q", m.activeModel, current)
+ }
+}
+
+func TestNewModelTUI_CustomModel(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "custom-model-xyz")
+ if m.modelIdx != len(p[0].Models) {
+ t.Errorf("modelIdx = %d, want %d for custom model", m.modelIdx, len(p[0].Models))
+ }
+}
+
+func TestModelTUI_IsCustomItem(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ if !m.isCustomItem(len(p[0].Models)) {
+ t.Error("expected true for custom item index")
+ }
+ if m.isCustomItem(0) {
+ t.Error("expected false for index 0")
+ }
+}
+
+func TestModelTUI_ItemCount(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ if m.itemCount() != len(p[0].Models)+1 {
+ t.Errorf("itemCount() = %d, want %d", m.itemCount(), len(p[0].Models)+1)
+ }
+}
+
+func TestModelTUI_SelectedModel(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 || len(p[0].Models) == 0 {
+ t.Skip("need provider with models")
+ }
+ m := newModelTUI(p[0], "")
+ got := m.selectedModel()
+ if got != p[0].Models[0] {
+ t.Errorf("selectedModel() = %q, want %q", got, p[0].Models[0])
+ }
+}
+
+func TestModelTUI_SelectedModel_OutOfBounds(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ m.modelIdx = 9999
+ got := m.selectedModel()
+ if got != "" {
+ t.Errorf("expected empty for out-of-bounds, got %q", got)
+ }
+}
+
+func TestModelTUI_Update_UpDown(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 || len(p[0].Models) < 2 {
+ t.Skip("need provider with at least 2 models")
+ }
+ m := newModelTUI(p[0], "")
+ result, _ := m.Update(downKey())
+ m2 := result.(modelTUIModel)
+ if m2.modelIdx != 1 {
+ t.Errorf("after down, modelIdx = %d, want 1", m2.modelIdx)
+ }
+ result, _ = m2.Update(upKey())
+ m3 := result.(modelTUIModel)
+ if m3.modelIdx != 0 {
+ t.Errorf("after up, modelIdx = %d, want 0", m3.modelIdx)
+ }
+}
+
+func TestModelTUI_Update_WindowSize(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ result, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
+ m2 := result.(modelTUIModel)
+ if m2.width != 100 || m2.height != 50 {
+ t.Errorf("size = %dx%d, want 100x50", m2.width, m2.height)
+ }
+}
+
+func TestModelTUI_Update_EscCancels(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ result, _ := m.Update(escKey())
+ m2 := result.(modelTUIModel)
+ if !m2.cancelled {
+ t.Error("expected cancelled after esc")
+ }
+}
+
+// --- View rendering tests (smoke) ---
+
+func TestProviderTUIView_StepProvider_OfficialTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ v := m.View()
+ if v.Content == "" {
+ t.Error("expected non-empty view")
+ }
+}
+
+func TestProviderTUIView_StepProvider_CustomTab(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "my-llm": {URL: "http://localhost", Model: "m"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ v := m.View()
+ if !strings.Contains(v.Content, "my-llm") {
+ t.Errorf("expected custom provider name in view, got %q", v.Content)
+ }
+}
+
+func TestProviderTUIView_StepProvider_CustomTab_CreatingCustom(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.creatingCustom = true
+ v := m.View()
+ if !strings.Contains(v.Content, "Add Custom Provider") {
+ t.Errorf("expected 'Add Custom Provider' in view")
+ }
+}
+
+func TestProviderTUIView_StepProvider_CustomTab_EditingCustom(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "ed": {URL: "http://ed"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.editingCustom = true
+ m.editTargetName = "ed"
+ v := m.View()
+ if !strings.Contains(v.Content, "Edit Custom Provider") {
+ t.Errorf("expected 'Edit Custom Provider' in view")
+ }
+}
+
+func TestProviderTUIView_StepProvider_ManualTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ v := m.View()
+ if !strings.Contains(v.Content, "Manual") {
+ t.Errorf("expected 'Manual' in view")
+ }
+}
+
+func TestProviderTUIView_StepProvider_ManualTab_InForm(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ m.inManualForm = true
+ v := m.View()
+ if !strings.Contains(v.Content, "Manual Configuration") {
+ t.Errorf("expected 'Manual Configuration' in view")
+ }
+}
+
+func TestProviderTUIView_StepProvider_ConfirmingDelete(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "del": {URL: "http://del"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.confirmingDelete = true
+ m.deleteTargetName = "del"
+ v := m.View()
+ if !strings.Contains(v.Content, "Confirm") {
+ t.Errorf("expected confirm help text in view")
+ }
+}
+
+func TestProviderTUIView_StepModel(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ v := m.View()
+ if !strings.Contains(v.Content, "Select a model") {
+ t.Errorf("expected 'Select a model' in view, got %q", v.Content)
+ }
+}
+
+func TestProviderTUIView_StepModel_CustomModel(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.customModel = true
+ v := m.View()
+ if v.Content == "" {
+ t.Error("expected non-empty view")
+ }
+}
+
+func TestProviderTUIView_StepModel_FormError(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.customModel = true
+ m.formError = "model name required"
+ v := m.View()
+ if !strings.Contains(v.Content, "model name required") {
+ t.Errorf("expected form error in view")
+ }
+}
+
+func TestProviderTUIView_StepModel_ConfirmingDeleteModel(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepModel
+ m.confirmingDeleteModel = true
+ m.deleteModelName = "gpt-4"
+ v := m.View()
+ if !strings.Contains(v.Content, "gpt-4") {
+ t.Errorf("expected delete model name in view")
+ }
+}
+
+func TestProviderTUIView_StepAPIKey(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.step = stepAPIKey
+ v := m.View()
+ if !strings.Contains(v.Content, "API Key") {
+ t.Errorf("expected 'API Key' in view, got %q", v.Content)
+ }
+}
+
+func TestProviderTUIView_StepAPIKey_CustomProvider(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "cp": {URL: "http://cp"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.step = stepAPIKey
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ v := m.View()
+ if !strings.Contains(v.Content, "cp") {
+ t.Errorf("expected custom provider name in API Key view")
+ }
+}
+
+func TestRenderTabBar_AllTabs(t *testing.T) {
+ for _, tab := range []providerTab{tabOfficial, tabCustom, tabManual} {
+ got := renderTabBar(tab)
+ if got == "" {
+ t.Errorf("renderTabBar(%d) returned empty", tab)
+ }
+ }
+}
+
+func TestModelTUI_View(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ v := m.View()
+ if !strings.Contains(v.Content, "Select a model") {
+ t.Errorf("expected 'Select a model' in view, got %q", v.Content)
+ }
+}
+
+func TestModelTUI_View_CustomModel(t *testing.T) {
+ p := llm.ListProviders()
+ if len(p) == 0 {
+ t.Skip("no providers")
+ }
+ m := newModelTUI(p[0], "")
+ m.customModel = true
+ v := m.View()
+ if v.Content == "" {
+ t.Error("expected non-empty view")
+ }
+}
+
+// --- result() tests ---
+
+func TestResult_OfficialTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ r := m.result()
+ if r.provider == "" && len(m.providers) > 0 {
+ t.Error("expected non-empty provider")
+ }
+}
+
+func TestResult_OfficialTab_WithMaskedKey(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.apiKeyMasked = true
+ m.apiKeyOriginal = "sk-secret"
+ r := m.result()
+ if r.apiKey != "sk-secret" {
+ t.Errorf("expected masked key, got %q", r.apiKey)
+ }
+}
+
+func TestResult_CustomTab_Creating(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.creatingCustom = true
+ m.cpNameInput.SetValue("new-prov")
+ m.cpURLInput.SetValue("http://url")
+ r := m.result()
+ if !r.isCustom {
+ t.Error("expected isCustom=true")
+ }
+ if r.provider != "new-prov" {
+ t.Errorf("provider = %q, want new-prov", r.provider)
+ }
+}
+
+func TestResult_CustomTab_Editing(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "ed": {URL: "http://ed", Model: "m1", Models: []string{"m1", "m2"}},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.editingCustom = true
+ m.editTargetName = "ed"
+ m.cpNameInput.SetValue("ed")
+ m.cpURLInput.SetValue("http://ed")
+ r := m.result()
+ if !r.isEdit {
+ t.Error("expected isEdit=true")
+ }
+ if r.model != "m1" {
+ t.Errorf("model = %q, want m1", r.model)
+ }
+}
+
+func TestResult_CustomTab_Selected(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "sel": {URL: "http://sel", Model: "gpt", Models: []string{"gpt"}},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ r := m.result()
+ if !r.isCustom {
+ t.Error("expected isCustom=true")
+ }
+ if r.provider != "sel" {
+ t.Errorf("provider = %q, want sel", r.provider)
+ }
+}
+
+func TestResult_CustomTab_OutOfBounds(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.customIdx = 999
+ r := m.result()
+ if r.provider != "" {
+ t.Errorf("expected empty provider, got %q", r.provider)
+ }
+}
+
+func TestResult_ManualTab(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ m.manualURLInput.SetValue("http://manual")
+ m.manualModelInput.SetValue("model-x")
+ r := m.result()
+ if !r.isManual {
+ t.Error("expected isManual=true")
+ }
+ if r.url != "http://manual" {
+ t.Errorf("url = %q, want http://manual", r.url)
+ }
+ if r.model != "model-x" {
+ t.Errorf("model = %q, want model-x", r.model)
+ }
+}
+
+func TestResult_ManualTab_MaskedToken(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ m.manualTokenMasked = true
+ m.manualTokenOriginal = "tok-secret"
+ r := m.result()
+ if r.apiKey != "tok-secret" {
+ t.Errorf("expected masked token, got %q", r.apiKey)
+ }
+}
+
+// --- loadExistingAPIKey tests ---
+
+func TestLoadExistingAPIKey_CustomTab_HasKey(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "cp": {URL: "http://cp", APIKey: "sk-key"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.loadExistingAPIKey()
+ if !m.apiKeyMasked {
+ t.Error("expected apiKeyMasked=true")
+ }
+ if m.apiKeyOriginal != "sk-key" {
+ t.Errorf("apiKeyOriginal = %q, want sk-key", m.apiKeyOriginal)
+ }
+}
+
+func TestLoadExistingAPIKey_CustomTab_NoKey(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "cp": {URL: "http://cp"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.loadExistingAPIKey()
+ if m.apiKeyMasked {
+ t.Error("expected apiKeyMasked=false")
+ }
+}
+
+func TestLoadExistingAPIKey_OfficialTab_NilCfg(t *testing.T) {
+ m := providerTUIModel{existingCfg: nil}
+ m.loadExistingAPIKey()
+ if m.apiKeyMasked {
+ t.Error("expected apiKeyMasked=false for nil cfg")
+ }
+}
+
+func TestLoadExistingAPIKey_OfficialTab_HasKey(t *testing.T) {
+ cfg := &Config{}
+ m := newProviderTUI(cfg, "")
+ if len(m.providers) == 0 {
+ t.Skip("no providers")
+ }
+ provName := m.providers[0].Name
+ cfg.Providers = map[string]ProviderEntry{
+ provName: {APIKey: "official-key"},
+ }
+ m.existingCfg = cfg
+ m.officialIdx = 0
+ m.loadExistingAPIKey()
+ if !m.apiKeyMasked {
+ t.Error("expected apiKeyMasked=true")
+ }
+ if m.apiKeyOriginal != "official-key" {
+ t.Errorf("apiKeyOriginal = %q, want official-key", m.apiKeyOriginal)
+ }
+}
+
+// --- selectedModelFromState tests ---
+
+func TestSelectedModelFromState_CustomModelInput(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.customModel = true
+ m.modelInput.SetValue("my-model")
+ got := m.selectedModelFromState()
+ if got != "my-model" {
+ t.Errorf("expected my-model, got %q", got)
+ }
+}
+
+func TestSelectedModelFromState_OutOfBounds(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.modelIdx = 9999
+ got := m.selectedModelFromState()
+ if got != "" {
+ t.Errorf("expected empty, got %q", got)
+ }
+}
+
+// --- syncSessionModelSelection tests ---
+
+func TestSyncSessionModelSelection_NilCfg(t *testing.T) {
+ m := providerTUIModel{existingCfg: nil}
+ err := m.syncSessionModelSelection()
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestSyncSessionModelSelection_EmptyModel(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.modelIdx = 9999
+ err := m.syncSessionModelSelection()
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+// --- viewCustomProviderForm field steps ---
+
+func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.creatingCustom = true
+ for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} {
+ m.cpStep = step
+ v := m.View()
+ if v.Content == "" {
+ t.Errorf("empty view for step %d", step)
+ }
+ }
+}
+
+func TestProviderTUIView_CustomForm_WithError(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.creatingCustom = true
+ m.formError = "name is required"
+ v := m.View()
+ if !strings.Contains(v.Content, "name is required") {
+ t.Error("expected form error in view")
+ }
+}
+
+// --- viewManualTab field steps ---
+
+func TestProviderTUIView_ManualForm_AllSteps(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ m.inManualForm = true
+ for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} {
+ m.manualStep = step
+ v := m.View()
+ if v.Content == "" {
+ t.Errorf("empty view for manual step %d", step)
+ }
+ }
+}
+
+func TestProviderTUIView_ManualForm_WithError(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabManual
+ m.inManualForm = true
+ m.formError = "URL required"
+ v := m.View()
+ if !strings.Contains(v.Content, "URL required") {
+ t.Error("expected form error in view")
+ }
+}
+
+func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) {
+ cfg := &Config{
+ Llm: LlmConfig{URL: "http://existing", Model: "old-model"},
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabManual
+ v := m.View()
+ if !strings.Contains(v.Content, "http://existing") {
+ t.Errorf("expected existing URL in manual tab")
+ }
+}
+
+// --- viewModel with custom tab and deleteModel ---
+
+func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) {
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "cp": {URL: "http://cp", Models: []string{"m1"}},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.step = stepModel
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ v := m.View()
+ if !strings.Contains(v.Content, "Delete") {
+ t.Errorf("expected delete help for custom tab model view")
+ }
+}
diff --git a/cmd/opencodereview/scan_cmd_test.go b/cmd/opencodereview/scan_cmd_test.go
index c20e20a..0205842 100644
--- a/cmd/opencodereview/scan_cmd_test.go
+++ b/cmd/opencodereview/scan_cmd_test.go
@@ -140,3 +140,110 @@ func TestParseScanFlags_HelpFlag(t *testing.T) {
t.Error("opts.showHelp should be true when -h is supplied")
}
}
+
+func TestParseScanFlags_RejectsNegativeMaxTokensBudget(t *testing.T) {
+ _, err := parseScanFlags([]string{"--max-tokens-budget", "-100"})
+ if err == nil {
+ t.Fatal("expected error for negative --max-tokens-budget")
+ }
+ if !strings.Contains(err.Error(), "--max-tokens-budget") {
+ t.Errorf("error message = %q; want it to mention --max-tokens-budget", err.Error())
+ }
+}
+
+func TestParseScanFlags_BooleanFlags(t *testing.T) {
+ opts, err := parseScanFlags([]string{"--no-plan", "--no-dedup", "--no-summary", "--preview"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.noPlan {
+ t.Error("noPlan should be true")
+ }
+ if !opts.noDedup {
+ t.Error("noDedup should be true")
+ }
+ if !opts.noSummary {
+ t.Error("noSummary should be true")
+ }
+ if !opts.preview {
+ t.Error("preview should be true")
+ }
+}
+
+func TestParseScanFlags_ModelOverride(t *testing.T) {
+ opts, err := parseScanFlags([]string{"--model", "claude-opus-4-6"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.model != "claude-opus-4-6" {
+ t.Errorf("model = %q, want claude-opus-4-6", opts.model)
+ }
+}
+
+func TestParseScanFlags_AllStringFlags(t *testing.T) {
+ opts, err := parseScanFlags([]string{
+ "--tools", "/tmp/tools.json",
+ "--rule", "/tmp/rule.json",
+ "--repo", "/tmp/repo",
+ "--exclude", "*.md,*.txt",
+ "--batch", "by-language",
+ "--background", "test context",
+ "--audience", "agent",
+ "-f", "json",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.toolConfigPath != "/tmp/tools.json" {
+ t.Errorf("toolConfigPath = %q", opts.toolConfigPath)
+ }
+ if opts.rulePath != "/tmp/rule.json" {
+ t.Errorf("rulePath = %q", opts.rulePath)
+ }
+ if opts.repoDir != "/tmp/repo" {
+ t.Errorf("repoDir = %q", opts.repoDir)
+ }
+ if opts.excludes != "*.md,*.txt" {
+ t.Errorf("excludes = %q", opts.excludes)
+ }
+ if opts.batch != "by-language" {
+ t.Errorf("batch = %q", opts.batch)
+ }
+ if opts.background != "test context" {
+ t.Errorf("background = %q", opts.background)
+ }
+ if opts.audience != "agent" {
+ t.Errorf("audience = %q", opts.audience)
+ }
+ if opts.outputFormat != "json" {
+ t.Errorf("outputFormat = %q", opts.outputFormat)
+ }
+}
+
+func TestParseScanFlags_IntFlags(t *testing.T) {
+ opts, err := parseScanFlags([]string{
+ "--concurrency", "16",
+ "--timeout", "20",
+ "--max-tools", "50",
+ "--max-git-procs", "32",
+ "--max-tokens-budget", "100000",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.concurrency != 16 {
+ t.Errorf("concurrency = %d", opts.concurrency)
+ }
+ if opts.perFileTimeout != 20 {
+ t.Errorf("perFileTimeout = %d", opts.perFileTimeout)
+ }
+ if opts.maxTools != 50 {
+ t.Errorf("maxTools = %d", opts.maxTools)
+ }
+ if opts.maxGitProcs != 32 {
+ t.Errorf("maxGitProcs = %d", opts.maxGitProcs)
+ }
+ if opts.maxTokensBudget != 100000 {
+ t.Errorf("maxTokensBudget = %d", opts.maxTokensBudget)
+ }
+}
diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go
new file mode 100644
index 0000000..8c9b819
--- /dev/null
+++ b/cmd/opencodereview/shared_test.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/rules"
+)
+
+func TestApplyCLIExcludes_Empty(t *testing.T) {
+ cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
+ applyCLIExcludes(cc, nil)
+ if len(cc.FileFilter.Exclude) != 1 {
+ t.Errorf("expected 1 exclude, got %d", len(cc.FileFilter.Exclude))
+ }
+}
+
+func TestApplyCLIExcludes_AppendsPatterns(t *testing.T) {
+ cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
+ applyCLIExcludes(cc, []string{"b", "c"})
+ if len(cc.FileFilter.Exclude) != 3 {
+ t.Errorf("expected 3 excludes, got %d", len(cc.FileFilter.Exclude))
+ }
+}
+
+func TestApplyCLIExcludes_NilFileFilter(t *testing.T) {
+ cc := &commonContext{}
+ applyCLIExcludes(cc, []string{"x"})
+ if cc.FileFilter == nil {
+ t.Fatal("expected FileFilter to be created")
+ }
+ if len(cc.FileFilter.Exclude) != 1 || cc.FileFilter.Exclude[0] != "x" {
+ t.Errorf("expected [x], got %v", cc.FileFilter.Exclude)
+ }
+}
+
+func TestNewQuietHandle_NoOp(t *testing.T) {
+ h := newQuietHandle("text", "developer")
+ if h.fn != nil {
+ t.Error("expected no-op handle for text/developer")
+ }
+ h.Restore()
+}
+
+func TestNewQuietHandle_JSON(t *testing.T) {
+ h := newQuietHandle("json", "developer")
+ if h.fn == nil {
+ t.Error("expected fn to be set for json format")
+ }
+ h.Restore()
+ if h.fn != nil {
+ t.Error("expected fn to be nil after Restore")
+ }
+}
+
+func TestNewQuietHandle_Agent(t *testing.T) {
+ h := newQuietHandle("text", "agent")
+ if h.fn == nil {
+ t.Error("expected fn to be set for agent audience")
+ }
+ h.Restore()
+}
+
+func TestQuietHandle_NilReceiver(t *testing.T) {
+ var h *quietHandle
+ h.Restore()
+}
+
+func TestQuietHandle_IdempotentRestore(t *testing.T) {
+ h := newQuietHandle("json", "developer")
+ h.Restore()
+ h.Restore()
+ if h.fn != nil {
+ t.Error("expected nil after double restore")
+ }
+}
+
+func TestResolveWorkingDir_CurrentDir(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer os.Chdir(origDir)
+ os.Chdir(dir)
+
+ absPath, isGit, err := resolveWorkingDir("", false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if absPath == "" {
+ t.Error("expected non-empty absPath")
+ }
+ if isGit {
+ t.Error("temp dir should not be a git repo")
+ }
+}
+
+func TestResolveWorkingDir_RequireGitFails(t *testing.T) {
+ dir := t.TempDir()
+ _, _, err := resolveWorkingDir(dir, true)
+ if err == nil {
+ t.Fatal("expected error for non-git dir with requireGit=true")
+ }
+}
+
+func TestResolveWorkingDir_NonExistent(t *testing.T) {
+ _, _, err := resolveWorkingDir(filepath.Join(t.TempDir(), "no-such-dir"), false)
+ if err == nil {
+ t.Fatal("expected error for non-existent path")
+ }
+}
+
+func TestResolveWorkingDir_GitRepo(t *testing.T) {
+ dir := t.TempDir()
+ gitDir := filepath.Join(dir, ".git")
+ if err := os.Mkdir(gitDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ absPath, isGit, err := resolveWorkingDir(dir, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if absPath == "" {
+ t.Error("expected non-empty absPath")
+ }
+ _ = isGit
+}
diff --git a/cmd/opencodereview/smallfiles_test.go b/cmd/opencodereview/smallfiles_test.go
new file mode 100644
index 0000000..cae63f2
--- /dev/null
+++ b/cmd/opencodereview/smallfiles_test.go
@@ -0,0 +1,248 @@
+package main
+
+import (
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestPrintVersion_Dev(t *testing.T) {
+ origVersion := Version
+ origCommit := GitCommit
+ origDate := BuildDate
+ defer func() {
+ Version = origVersion
+ GitCommit = origCommit
+ BuildDate = origDate
+ }()
+
+ Version = "dev"
+ GitCommit = ""
+ BuildDate = ""
+
+ got := captureStdout(t, func() {
+ printVersion()
+ })
+ if !strings.Contains(got, "open-code-review dev") {
+ t.Errorf("expected 'open-code-review dev', got %q", got)
+ }
+ if !strings.Contains(got, runtime.GOOS+"/"+runtime.GOARCH) {
+ t.Errorf("expected OS/ARCH, got %q", got)
+ }
+}
+
+func TestPrintVersion_WithCommitAndDate(t *testing.T) {
+ origVersion := Version
+ origCommit := GitCommit
+ origDate := BuildDate
+ defer func() {
+ Version = origVersion
+ GitCommit = origCommit
+ BuildDate = origDate
+ }()
+
+ Version = "1.2.3"
+ GitCommit = "abc1234"
+ BuildDate = "2026-01-01"
+
+ got := captureStdout(t, func() {
+ printVersion()
+ })
+ if !strings.Contains(got, "1.2.3") {
+ t.Errorf("expected version, got %q", got)
+ }
+ if !strings.Contains(got, "abc1234") {
+ t.Errorf("expected commit, got %q", got)
+ }
+ if !strings.Contains(got, "2026-01-01") {
+ t.Errorf("expected build date, got %q", got)
+ }
+}
+
+func TestParseViewerFlags_Defaults(t *testing.T) {
+ opts, err := parseViewerFlags(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.addr != "localhost:5483" {
+ t.Errorf("addr = %q, want localhost:5483", opts.addr)
+ }
+ if opts.showHelp {
+ t.Error("showHelp should be false")
+ }
+}
+
+func TestParseViewerFlags_CustomAddr(t *testing.T) {
+ opts, err := parseViewerFlags([]string{"--addr", ":3000"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.addr != ":3000" {
+ t.Errorf("addr = %q, want :3000", opts.addr)
+ }
+}
+
+func TestParseViewerFlags_Help(t *testing.T) {
+ opts, err := parseViewerFlags([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.showHelp {
+ t.Error("expected showHelp=true")
+ }
+}
+
+func TestRunLLM_NoArgs(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runLLM(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "LLM utility") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunLLM_UnknownSubcommand(t *testing.T) {
+ err := runLLM([]string{"bogus"})
+ if err == nil {
+ t.Fatal("expected error for unknown subcommand")
+ }
+ if !strings.Contains(err.Error(), "unknown llm sub-command") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestRunRules_NoArgs(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runRules(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "ocr rules") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunRules_Help(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runRules([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "ocr rules") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunRules_UnknownSubcommand(t *testing.T) {
+ err := runRules([]string{"bogus"})
+ if err == nil {
+ t.Fatal("expected error for unknown subcommand")
+ }
+}
+
+func TestRunRules_HelpAltFlag(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runRules([]string{"--help"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "ocr rules") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunRulesCheck_Help(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runRulesCheck([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "ocr rules check") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunRulesCheck_NoArgs(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runRulesCheck(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "ocr rules check") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestRunLLMProviders(t *testing.T) {
+ got := captureStdout(t, func() {
+ runLLMProviders()
+ })
+ if !strings.Contains(got, "Built-in providers") {
+ t.Errorf("expected provider listing, got %q", got)
+ }
+}
+
+func TestRunViewer_Help(t *testing.T) {
+ got := captureStdout(t, func() {
+ err := runViewer([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if !strings.Contains(got, "Session history") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestPrintReviewUsage(t *testing.T) {
+ got := captureStdout(t, func() {
+ printReviewUsage()
+ })
+ if !strings.Contains(got, "ocr review") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestPrintTopLevelUsage(t *testing.T) {
+ got := captureStdout(t, func() {
+ printTopLevelUsage()
+ })
+ if !strings.Contains(got, "OpenCodeReview") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestPrintViewerUsage(t *testing.T) {
+ got := captureStdout(t, func() {
+ printViewerUsage()
+ })
+ if !strings.Contains(got, "Session history") {
+ t.Errorf("expected viewer usage text, got %q", got)
+ }
+}
+
+func TestPrintRulesCheckUsage(t *testing.T) {
+ got := captureStdout(t, func() {
+ printRulesCheckUsage()
+ })
+ if !strings.Contains(got, "ocr rules check") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
+
+func TestPrintScanUsage(t *testing.T) {
+ got := captureStdout(t, func() {
+ printScanUsage()
+ })
+ if !strings.Contains(got, "ocr scan") {
+ t.Errorf("expected usage text, got %q", got)
+ }
+}
From 793bbc6a61b5f7c1f987828a29f5c47a951611ec Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 10:43:45 +0800
Subject: [PATCH 13/87] test: improve coverage for telemetry package and
exclude extensions from build targets
Add comprehensive tests for events, metrics, provider, shutdown, span,
and exporter in the telemetry package. Update Makefile to exclude the
extensions directory from test, fmt, vet, and check targets.
---
Makefile | 12 ++-
internal/telemetry/events_test.go | 152 ++++++++++++++++++++++++++++
internal/telemetry/exporter_test.go | 76 ++++++++++++++
internal/telemetry/metrics_test.go | 34 ++++++-
internal/telemetry/provider_test.go | 114 +++++++++++++++++++++
internal/telemetry/shutdown_test.go | 9 ++
internal/telemetry/span_test.go | 101 +++++++++++++++++-
7 files changed, 490 insertions(+), 8 deletions(-)
create mode 100644 internal/telemetry/exporter_test.go
diff --git a/Makefile b/Makefile
index fd1e389..bb83bc0 100644
--- a/Makefile
+++ b/Makefile
@@ -31,8 +31,10 @@ endef
build:
$(GO) build -ldflags "$(LD_FLAGS)" -o $(DIST_DIR)/$(BINARY_NAME) ./cmd/opencodereview
+PACKAGES := $(shell $(GO) list ./... | grep -v /extensions/)
+
test:
- LC_ALL=C $(GO) test -v -race -count=1 ./...
+ LC_ALL=C $(GO) test -v -race -count=1 $(PACKAGES)
clean:
rm -rf $(DIST_DIR)
@@ -44,15 +46,15 @@ help: build
$(DIST_DIR)/$(BINARY_NAME) -h
fmt:
- $(GO) fmt ./...
+ $(GO) fmt $(PACKAGES)
vet:
- LC_ALL=C $(GO) vet ./...
+ LC_ALL=C $(GO) vet $(PACKAGES)
check:
$(GO) mod tidy
- $(GO) fmt ./...
- LC_ALL=C $(GO) vet ./...
+ $(GO) fmt $(PACKAGES)
+ LC_ALL=C $(GO) vet $(PACKAGES)
@echo "check passed"
# ── Cross-platform targets ───────────────────────────────────────────────────
diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go
index 88e8b02..1bcd170 100644
--- a/internal/telemetry/events_test.go
+++ b/internal/telemetry/events_test.go
@@ -1,10 +1,162 @@
package telemetry
import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
"testing"
"time"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
+func setupEnabledTelemetry(t *testing.T) {
+ t.Helper()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithResource(resource.Default()))
+ mp := sdkmetric.NewMeterProvider(sdkmetric.WithResource(resource.Default()))
+ otel.SetTracerProvider(tp)
+ otel.SetMeterProvider(mp)
+ tracerProvider = tp
+ meterProvider = mp
+ initialized = true
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error { return tp.Shutdown(ctx) },
+ func(ctx context.Context) error { return mp.Shutdown(ctx) },
+ }
+ initMetricsOnce = false
+ t.Cleanup(func() {
+ _ = tp.Shutdown(context.Background())
+ _ = mp.Shutdown(context.Background())
+ tracerProvider = nil
+ meterProvider = nil
+ initialized = false
+ shutdownFuncs = nil
+ initMetricsOnce = false
+ })
+}
+
+func captureStderr(t *testing.T, fn func()) string {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldStderr := os.Stderr
+ os.Stderr = w
+ defer func() { os.Stderr = oldStderr }()
+
+ fn()
+ w.Close()
+
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+func TestEvent_Enabled(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ Event(ctx, "test.event", attribute.String("key", "value"))
+}
+
+func TestEvent_Disabled(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+ defer func() { initialized = false }()
+ Event(context.Background(), "test.event")
+}
+
+func TestEvent_NilCtx(t *testing.T) {
+ setupEnabledTelemetry(t)
+ Event(nil, "test.event") //nolint:staticcheck
+}
+
+func TestEventf_Enabled(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ Eventf(ctx, "test.eventf", "hello world", attribute.Int("count", 5))
+}
+
+func TestErrorEvent_Enabled(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ ErrorEvent(ctx, "test.error", errors.New("something failed"), attribute.String("detail", "extra"))
+}
+
+func TestErrorEvent_NilErr(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ ErrorEvent(ctx, "test.error", nil)
+}
+
+func TestErrorEvent_NilCtx(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ErrorEvent(nil, "test.error", errors.New("fail")) //nolint:staticcheck
+}
+
+func TestErrorEvent_Disabled(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+ defer func() { initialized = false }()
+ ErrorEvent(context.Background(), "test.error", errors.New("fail"))
+}
+
+func TestPhaseEvent_Success(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ PhaseEvent(ctx, "scan", "main.go", 500*time.Millisecond, nil)
+}
+
+func TestPhaseEvent_WithError(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ PhaseEvent(ctx, "scan", "main.go", 500*time.Millisecond, errors.New("parse error"))
+}
+
+func TestPrintTraceSummary_WithTokenDetails(t *testing.T) {
+ PrintTraceSummary(5, 10, 1000, 200, 1200, 0, 0, 3*time.Second)
+}
+
+func TestPrintTraceSummary_WithCacheTokens(t *testing.T) {
+ PrintTraceSummary(3, 2, 500, 100, 600, 200, 50, 2*time.Second)
+}
+
+func TestPrintTraceSummary_NoTokenDetails(t *testing.T) {
+ PrintTraceSummary(2, 1, 0, 0, 500, 0, 0, 1*time.Second)
+}
+
+func TestPrintToolCallStarted_WithArgs(t *testing.T) {
+ PrintToolCallStarted("file_read", map[string]any{"path": "main.go"})
+}
+
+func TestPrintToolCallStarted_NoArgs(t *testing.T) {
+ PrintToolCallStarted("list_files", nil)
+}
+
+func TestPrintToolCallFinished(t *testing.T) {
+ PrintToolCallFinished("file_read", 123*time.Millisecond)
+}
+
+func TestPrintToolCallError(t *testing.T) {
+ out := captureStderr(t, func() {
+ PrintToolCallError("file_read", fmt.Errorf("permission denied"))
+ })
+ if !strings.Contains(out, "✘ file_read") {
+ t.Errorf("expected tool name with X mark, got %q", out)
+ }
+ if !strings.Contains(out, "permission denied") {
+ t.Errorf("expected error message, got %q", out)
+ }
+}
+
func TestFormatDuration(t *testing.T) {
tests := []struct {
dur time.Duration
diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go
new file mode 100644
index 0000000..64f8573
--- /dev/null
+++ b/internal/telemetry/exporter_test.go
@@ -0,0 +1,76 @@
+package telemetry
+
+import (
+ "context"
+ "testing"
+
+ "go.opentelemetry.io/otel/sdk/resource"
+)
+
+func TestNewStdoutTraceExporter(t *testing.T) {
+ exp, err := newStdoutTraceExporter()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if exp == nil {
+ t.Error("expected non-nil exporter")
+ }
+}
+
+func TestNewStdoutMetricExporter(t *testing.T) {
+ exp, err := newStdoutMetricExporter()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if exp == nil {
+ t.Error("expected non-nil exporter")
+ }
+}
+
+func TestInitConsoleProviders(t *testing.T) {
+ tracerProvider = nil
+ meterProvider = nil
+ shutdownFuncs = nil
+ defer func() {
+ for _, fn := range shutdownFuncs {
+ _ = fn(context.Background())
+ }
+ tracerProvider = nil
+ meterProvider = nil
+ shutdownFuncs = nil
+ }()
+
+ initConsoleProviders(resource.Default())
+ if tracerProvider == nil {
+ t.Error("expected tracerProvider to be set after initConsoleProviders")
+ }
+ if meterProvider == nil {
+ t.Error("expected meterProvider to be set after initConsoleProviders")
+ }
+ if len(shutdownFuncs) != 2 {
+ t.Errorf("expected 2 shutdown funcs, got %d", len(shutdownFuncs))
+ }
+}
+
+func TestInitOTLPProviders_InvalidEndpoint(t *testing.T) {
+ tracerProvider = nil
+ meterProvider = nil
+ shutdownFuncs = nil
+ defer func() {
+ for _, fn := range shutdownFuncs {
+ _ = fn(context.Background())
+ }
+ tracerProvider = nil
+ meterProvider = nil
+ shutdownFuncs = nil
+ }()
+
+ cfg := Config{
+ Exporter: "otlp",
+ OTLPEndpoint: "localhost:0",
+ }
+ initOTLPProviders(context.Background(), resource.Default(), cfg)
+ if tracerProvider == nil {
+ t.Error("expected tracerProvider to be set (OTLP exporter creation is lazy)")
+ }
+}
diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go
index c678964..a028527 100644
--- a/internal/telemetry/metrics_test.go
+++ b/internal/telemetry/metrics_test.go
@@ -24,7 +24,39 @@ func TestRecordFunctions_DisabledTelemetry(t *testing.T) {
}
func TestCheckMetricErr(t *testing.T) {
- // Should not panic
checkMetricErr(nil)
checkMetricErr(fmt.Errorf("some error"))
}
+
+func TestRecordFunctions_EnabledTelemetry(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+
+ RecordReviewDuration(ctx, 5*time.Second)
+ RecordFilesReviewed(ctx, 10)
+ RecordCommentsGenerated(ctx, 3)
+ RecordLLMRequest(ctx, "gpt-4", 2*time.Second, 1000, "ok")
+ RecordLLMRequest(ctx, "gpt-4", 1*time.Second, 0, "error")
+ RecordToolCall(ctx, "file_read", 100*time.Millisecond, true)
+ RecordToolCall(ctx, "file_read", 200*time.Millisecond, false)
+}
+
+func TestEnsureMetrics_Idempotent(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ensureMetrics()
+ ensureMetrics()
+ if mReviewDuration == nil {
+ t.Error("expected mReviewDuration to be initialized")
+ }
+ if mFilesReviewed == nil {
+ t.Error("expected mFilesReviewed to be initialized")
+ }
+}
+
+func TestGetMeter(t *testing.T) {
+ setupEnabledTelemetry(t)
+ m := getMeter()
+ if m == nil {
+ t.Error("expected non-nil meter")
+ }
+}
diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go
index 1454e54..3895636 100644
--- a/internal/telemetry/provider_test.go
+++ b/internal/telemetry/provider_test.go
@@ -2,6 +2,7 @@ package telemetry
import (
"context"
+ "os"
"testing"
)
@@ -50,3 +51,116 @@ func TestContentLogging_Disabled(t *testing.T) {
t.Error("expected ContentLogging()=false when telemetry disabled")
}
}
+
+func TestContentLogging_EnabledWithEnv(t *testing.T) {
+ setupEnabledTelemetry(t)
+ t.Setenv("OCR_CONTENT_LOGGING", "1")
+ if !ContentLogging() {
+ t.Error("expected ContentLogging()=true when enabled and env var set")
+ }
+}
+
+func TestContentLogging_EnabledWithoutEnv(t *testing.T) {
+ setupEnabledTelemetry(t)
+ t.Setenv("OCR_CONTENT_LOGGING", "")
+ os.Unsetenv("OCR_CONTENT_LOGGING")
+ if ContentLogging() {
+ t.Error("expected ContentLogging()=false when enabled but env var not set")
+ }
+}
+
+func TestInit_AlreadyInitialized(t *testing.T) {
+ initialized = true
+ shutdownFuncs = nil
+ defer func() {
+ initialized = false
+ shutdownFuncs = nil
+ }()
+
+ result := Init(context.Background())
+ if result {
+ t.Error("expected false when already initialized with no shutdown funcs")
+ }
+}
+
+func TestInit_AlreadyInitializedWithShutdowns(t *testing.T) {
+ initialized = true
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error { return nil },
+ }
+ defer func() {
+ initialized = false
+ shutdownFuncs = nil
+ }()
+
+ result := Init(context.Background())
+ if !result {
+ t.Error("expected true when already initialized with shutdown funcs")
+ }
+}
+
+func TestInit_DisabledByDefault(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+ tracerProvider = nil
+ meterProvider = nil
+
+ envKeys := []string{
+ "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME",
+ "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
+ "OCR_CONTENT_LOGGING",
+ }
+ for _, k := range envKeys {
+ t.Setenv(k, "")
+ os.Unsetenv(k)
+ }
+
+ defer func() {
+ initialized = false
+ shutdownFuncs = nil
+ tracerProvider = nil
+ meterProvider = nil
+ }()
+
+ result := Init(context.Background())
+ if result {
+ t.Error("expected false when telemetry is not enabled via env")
+ }
+}
+
+func TestInit_EnabledConsole(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+ tracerProvider = nil
+ meterProvider = nil
+
+ t.Setenv("OCR_ENABLE_TELEMETRY", "1")
+ envKeys := []string{
+ "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
+ }
+ for _, k := range envKeys {
+ t.Setenv(k, "")
+ os.Unsetenv(k)
+ }
+
+ defer func() {
+ for _, fn := range shutdownFuncs {
+ _ = fn(context.Background())
+ }
+ initialized = false
+ shutdownFuncs = nil
+ tracerProvider = nil
+ meterProvider = nil
+ }()
+
+ result := Init(context.Background())
+ if !result {
+ t.Error("expected true when telemetry is enabled")
+ }
+ if tracerProvider == nil {
+ t.Error("expected tracerProvider to be set")
+ }
+ if meterProvider == nil {
+ t.Error("expected meterProvider to be set")
+ }
+}
diff --git a/internal/telemetry/shutdown_test.go b/internal/telemetry/shutdown_test.go
index a56f999..e8c8dcf 100644
--- a/internal/telemetry/shutdown_test.go
+++ b/internal/telemetry/shutdown_test.go
@@ -69,3 +69,12 @@ func TestShutdownWithTimeout(t *testing.T) {
t.Error("expected shutdown function to be called via ShutdownWithTimeout")
}
}
+
+func TestShutdownWithTimeout_Error(t *testing.T) {
+ shutdownFuncs = []func(context.Context) error{
+ func(ctx context.Context) error { return errors.New("shutdown fail") },
+ }
+ defer func() { shutdownFuncs = nil }()
+
+ ShutdownWithTimeout(context.Background(), 5*time.Second)
+}
diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go
index 033561b..a8ca9d1 100644
--- a/internal/telemetry/span_test.go
+++ b/internal/telemetry/span_test.go
@@ -1,10 +1,13 @@
package telemetry
import (
+ "context"
+ "errors"
"fmt"
"testing"
"go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
)
func TestAnyToAttr(t *testing.T) {
@@ -31,13 +34,107 @@ func TestAnyToAttr(t *testing.T) {
}
}
+func TestStartSpan_Enabled(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ newCtx, span := StartSpan(ctx, "test.span")
+ defer span.End()
+ if newCtx == nil {
+ t.Error("expected non-nil context")
+ }
+ if !span.SpanContext().IsValid() {
+ t.Error("expected valid span context when telemetry is enabled")
+ }
+}
+
+func TestStartSpan_Disabled(t *testing.T) {
+ initialized = false
+ shutdownFuncs = nil
+ defer func() { initialized = false }()
+
+ ctx := context.Background()
+ newCtx, span := StartSpan(ctx, "test.span")
+ if newCtx != ctx {
+ t.Error("expected same context when disabled")
+ }
+ _ = span
+}
+
+func TestStartSpan_WithOptions(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ _, span := StartSpan(ctx, "test.span", trace.WithAttributes(attribute.String("key", "val")))
+ defer span.End()
+ if !span.SpanContext().IsValid() {
+ t.Error("expected valid span")
+ }
+}
+
+func TestEndSpan_NoError(t *testing.T) {
+ setupEnabledTelemetry(t)
+ _, span := StartSpan(context.Background(), "test.span")
+ EndSpan(span, nil)
+}
+
+func TestEndSpan_WithError(t *testing.T) {
+ setupEnabledTelemetry(t)
+ _, span := StartSpan(context.Background(), "test.span")
+ EndSpan(span, errors.New("something went wrong"))
+}
+
+func TestStartToolSpan_Enabled(t *testing.T) {
+ setupEnabledTelemetry(t)
+ ctx := context.Background()
+ newCtx, span := StartToolSpan(ctx, "file_read")
+ defer span.End()
+ if newCtx == nil {
+ t.Error("expected non-nil context")
+ }
+ if !span.SpanContext().IsValid() {
+ t.Error("expected valid span for tool")
+ }
+}
+
+func TestGetTracer(t *testing.T) {
+ setupEnabledTelemetry(t)
+ tracer := getTracer()
+ if tracer == nil {
+ t.Error("expected non-nil tracer")
+ }
+}
+
+func TestSetAttr_AllTypes(t *testing.T) {
+ setupEnabledTelemetry(t)
+ _, span := StartSpan(context.Background(), "test.setattr")
+ defer span.End()
+
+ SetAttr(span, "str", "hello")
+ SetAttr(span, "int", 42)
+ SetAttr(span, "int64", int64(100))
+ SetAttr(span, "bool", true)
+ SetAttr(span, "float64", 3.14)
+ SetAttr(span, "default", []int{1, 2})
+}
+
func TestSetAttr_NilSpan(t *testing.T) {
- // Should not panic
SetAttr(nil, "key", "value")
}
+func TestRecordToolResult_Success(t *testing.T) {
+ setupEnabledTelemetry(t)
+ _, span := StartSpan(context.Background(), "test.tool")
+ RecordToolResult(span, "file_read", 123, nil)
+ span.End()
+}
+
+func TestRecordToolResult_Error(t *testing.T) {
+ setupEnabledTelemetry(t)
+ _, span := StartSpan(context.Background(), "test.tool")
+ RecordToolResult(span, "file_read", 456, errors.New("read failed"))
+ span.End()
+}
+
func TestRecordToolResult_NilSpan(t *testing.T) {
- // Should not panic
RecordToolResult(nil, "tool", 100, nil)
RecordToolResult(nil, "tool", 100, fmt.Errorf("err"))
}
From 30c083c0de4ca33e470240ce2fa20250541101b2 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 11:00:23 +0800
Subject: [PATCH 14/87] test: improve coverage for tool package from 79.5% to
93.6%
Add tests for response_message, stub providers, code_search Execute/Tool,
filereader commit mode with and without Runner, file_find git repo modes,
file_read error paths, and comment_collector ReplaceSince edge cases.
---
internal/tool/code_search_test.go | 155 +++++++++++++++++++++++
internal/tool/comment_collector_test.go | 24 ++++
internal/tool/file_find_test.go | 158 ++++++++++++++++++++++++
internal/tool/file_read_test.go | 105 ++++++++++++++++
internal/tool/filereader_read_test.go | 87 +++++++++++++
internal/tool/response_message_test.go | 33 +++++
internal/tool/stub_test.go | 48 +++++++
7 files changed, 610 insertions(+)
create mode 100644 internal/tool/response_message_test.go
create mode 100644 internal/tool/stub_test.go
diff --git a/internal/tool/code_search_test.go b/internal/tool/code_search_test.go
index 4002122..c2d0fb9 100644
--- a/internal/tool/code_search_test.go
+++ b/internal/tool/code_search_test.go
@@ -2,12 +2,15 @@ package tool
import (
"context"
+ "errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
+
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
)
func TestBuildGrepArgs_WorkspaceMode(t *testing.T) {
@@ -380,3 +383,155 @@ func TestGitGrep_NonGitDirectoryNoMatch(t *testing.T) {
t.Errorf("expected 'No matches found', got: %q", out)
}
}
+
+func TestCodeSearchProvider_Tool(t *testing.T) {
+ p := NewCodeSearch(&FileReader{RepoDir: "/tmp"})
+ if p.Tool() != CodeSearch {
+ t.Errorf("Tool() = %v, want CodeSearch", p.Tool())
+ }
+}
+
+func TestCodeSearchProvider_Execute_BlankSearchText(t *testing.T) {
+ p := NewCodeSearch(&FileReader{RepoDir: "/tmp"})
+ got, err := p.Execute(context.Background(), map[string]any{"search_text": " "})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "Error: search_text is blank" {
+ t.Errorf("Execute() = %q, want blank error", got)
+ }
+}
+
+func TestCodeSearchProvider_Execute_Found(t *testing.T) {
+ dir := setupTestRepo(t)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "search_text": "Hello",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "hello.go") {
+ t.Errorf("expected hello.go in result, got: %s", got)
+ }
+}
+
+func TestCodeSearchProvider_Execute_WithFilePatterns(t *testing.T) {
+ dir := setupTestRepo(t)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "search_text": "Util",
+ "file_patterns": []any{"pkg/"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "util.go") {
+ t.Errorf("expected util.go in result, got: %s", got)
+ }
+}
+
+func TestCodeSearchProvider_Execute_CaseSensitive(t *testing.T) {
+ dir := setupTestRepo(t)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "search_text": "hello",
+ "case_sensitive": true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(got, "Hello") {
+ t.Errorf("case-sensitive search for 'hello' should not match 'Hello', got: %s", got)
+ }
+}
+
+func TestCodeSearchProvider_Execute_PerlRegexp(t *testing.T) {
+ dir := setupTestRepo(t)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "search_text": "Hell\\w+",
+ "use_perl_regexp": true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "hello.go") {
+ t.Errorf("expected hello.go in perl regexp result, got: %s", got)
+ }
+}
+
+func TestGitGrep_WithRunner(t *testing.T) {
+ dir := setupTestRepo(t)
+ runner := gitcmd.New(4)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner})
+
+ result, err := p.gitGrep(context.Background(), "Hello", false, false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(result, "hello.go") {
+ t.Errorf("expected hello.go in result via Runner, got: %s", result)
+ }
+}
+
+func TestGitGrep_WithRunner_NoMatch(t *testing.T) {
+ dir := setupTestRepo(t)
+ runner := gitcmd.New(4)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner})
+
+ result, err := p.gitGrep(context.Background(), "nonexistentXYZ", false, false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result != "No matches found" {
+ t.Errorf("expected 'No matches found', got: %s", result)
+ }
+}
+
+func TestGitGrep_WithRunner_CommitMode(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+ runner := gitcmd.New(4)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Ref: commit, Mode: ModeCommit, Runner: runner})
+
+ result, err := p.gitGrep(context.Background(), "Hello", false, false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(result, "hello.go") {
+ t.Errorf("expected hello.go in result via Runner commit mode, got: %s", result)
+ }
+}
+
+func TestGitGrep_Timeout(t *testing.T) {
+ dir := setupTestRepo(t)
+ p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ result, err := p.gitGrep(ctx, "Hello", false, false, nil)
+ if err != nil {
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context.Canceled, got: %v", err)
+ }
+ return
+ }
+ if !strings.Contains(result, "timed out") && !strings.Contains(result, "No matches found") {
+ t.Errorf("expected timeout or no matches message, got: %s", result)
+ }
+}
+
+func TestBuildGrepArgs_NoIndex(t *testing.T) {
+ p := NewCodeSearch(&FileReader{RepoDir: "/tmp", Ref: ""})
+ args := p.buildGrepArgs("foo", false, false, true, nil)
+
+ assertContains(t, args, "--no-index")
+ assertContains(t, args, "--exclude-standard")
+ assertNotContains(t, args, "--untracked")
+}
diff --git a/internal/tool/comment_collector_test.go b/internal/tool/comment_collector_test.go
index e2628e8..a1198ca 100644
--- a/internal/tool/comment_collector_test.go
+++ b/internal/tool/comment_collector_test.go
@@ -150,3 +150,27 @@ func TestCommentCollector_RemoveByPathAndIndices_NoMatch(t *testing.T) {
t.Error("remove from non-matching path should be no-op")
}
}
+
+func TestCommentCollector_ReplaceSince_NegativeSnap(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "keep"))
+ c.ReplaceSince(-1, []model.LlmComment{cm("new.go", "replaced")})
+
+ got := c.Comments()
+ if len(got) != 1 || got[0].Path != "new.go" {
+ t.Errorf("ReplaceSince(-1) should replace all, got %v", got)
+ }
+}
+
+func TestCommentCollector_ReplaceSince_Zero(t *testing.T) {
+ c := NewCommentCollector()
+ c.Add(cm("a.go", "x"))
+ c.Add(cm("b.go", "y"))
+
+ c.ReplaceSince(0, []model.LlmComment{cm("only.go", "z")})
+
+ got := c.Comments()
+ if len(got) != 1 || got[0].Path != "only.go" {
+ t.Errorf("ReplaceSince(0) should replace all, got %v", got)
+ }
+}
diff --git a/internal/tool/file_find_test.go b/internal/tool/file_find_test.go
index 6340cd8..3f4dace 100644
--- a/internal/tool/file_find_test.go
+++ b/internal/tool/file_find_test.go
@@ -3,9 +3,12 @@ package tool
import (
"context"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
+
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
)
// TestFileFind_NonGitDirectoryFallback verifies file_find works in a plain
@@ -64,3 +67,158 @@ func TestFileFind_NonGitDirectoryNoMatch(t *testing.T) {
t.Errorf("expected not-found sentinel, got: %q", out)
}
}
+
+func TestFileFindProvider_Tool(t *testing.T) {
+ p := NewFileFind(&FileReader{RepoDir: "/tmp"})
+ if p.Tool() != FileFind {
+ t.Errorf("Tool() = %v, want FileFind", p.Tool())
+ }
+}
+
+func TestFileFind_BlankQuery(t *testing.T) {
+ p := NewFileFind(&FileReader{RepoDir: "/tmp"})
+ got, err := p.Execute(context.Background(), map[string]any{"query_name": " "})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(got, "not found") {
+ t.Errorf("expected not-found for blank query, got: %q", got)
+ }
+}
+
+func setupFileFindRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ run := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("setup %v: %v\n%s", args, err, out)
+ }
+ }
+ run("git", "init")
+ run("git", "config", "user.email", "test@test.com")
+ run("git", "config", "user.name", "Test")
+
+ write := func(rel, content string) {
+ full := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("main.go", "package main\n")
+ write("pkg/util.go", "package pkg\n")
+ write("Makefile", "all:\n")
+ write("Dockerfile", "FROM scratch\n")
+ write("LICENSE", "MIT\n")
+ write("data_binary", "binary\n")
+
+ run("git", "add", ".")
+ run("git", "commit", "-m", "init")
+ return dir
+}
+
+func TestFileFind_GitRepo_WorkspaceMode(t *testing.T) {
+ dir := setupFileFindRepo(t)
+ p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{"query_name": ".go"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") {
+ t.Errorf("expected .go files, got: %s", got)
+ }
+}
+
+func TestFileFind_GitRepo_CommitMode(t *testing.T) {
+ dir := setupFileFindRepo(t)
+ cmd := exec.Command("git", "rev-parse", "HEAD")
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ commit := strings.TrimSpace(string(out))
+
+ p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit})
+
+ got, execErr := p.Execute(context.Background(), map[string]any{"query_name": ".go"})
+ if execErr != nil {
+ t.Fatal(execErr)
+ }
+ if !strings.Contains(got, "main.go") {
+ t.Errorf("expected main.go in commit mode, got: %s", got)
+ }
+}
+
+func TestFileFind_GitRepo_WithRunner(t *testing.T) {
+ dir := setupFileFindRepo(t)
+ runner := gitcmd.New(4)
+ p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner})
+
+ got, err := p.Execute(context.Background(), map[string]any{"query_name": ".go"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "main.go") {
+ t.Errorf("expected main.go via Runner, got: %s", got)
+ }
+}
+
+func TestFileFind_CaseSensitive(t *testing.T) {
+ dir := setupFileFindRepo(t)
+ p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "query_name": "makefile",
+ "case_sensitive": false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "Makefile") {
+ t.Errorf("case-insensitive should find Makefile, got: %s", got)
+ }
+
+ got2, err2 := p.Execute(context.Background(), map[string]any{
+ "query_name": "makefile",
+ "case_sensitive": true,
+ })
+ if err2 != nil {
+ t.Fatal(err2)
+ }
+ if strings.Contains(got2, "Makefile") {
+ t.Errorf("case-sensitive should not find Makefile when searching 'makefile', got: %s", got2)
+ }
+}
+
+func TestShouldSkipFile(t *testing.T) {
+ tests := []struct {
+ path string
+ want bool
+ }{
+ {"main.go", false},
+ {"pkg/util.go", false},
+ {"README.md", false},
+ {"Makefile", false},
+ {"Dockerfile", false},
+ {"LICENSE", false},
+ {"Vagrantfile", false},
+ {"Containerfile", false},
+ {"some_binary", true},
+ {"dir/unknown_file", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ got := shouldSkipFile(tt.path)
+ if got != tt.want {
+ t.Errorf("shouldSkipFile(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/tool/file_read_test.go b/internal/tool/file_read_test.go
index 8647447..954b01f 100644
--- a/internal/tool/file_read_test.go
+++ b/internal/tool/file_read_test.go
@@ -330,6 +330,111 @@ func TestExecute_Truncation(t *testing.T) {
}
}
+func TestFileReadProvider_Tool(t *testing.T) {
+ p := NewFileRead(&FileReader{RepoDir: "/tmp"})
+ if p.Tool() != FileRead {
+ t.Errorf("Tool() = %v, want FileRead", p.Tool())
+ }
+}
+
+func TestExecute_EmptyFilePath(t *testing.T) {
+ fr := &FileReader{RepoDir: t.TempDir(), Mode: ModeWorkspace}
+ p := NewFileRead(fr)
+
+ got, err := p.Execute(context.Background(), map[string]any{"file_path": ""})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "Error: file_path is required" {
+ t.Errorf("Execute() = %q, want file_path required error", got)
+ }
+}
+
+func TestExecute_InvalidLineRange(t *testing.T) {
+ dir := t.TempDir()
+ writeTestFile(t, dir, "test.txt", "a\nb\nc\n")
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ p := NewFileRead(fr)
+
+ _, err := p.Execute(context.Background(), map[string]any{
+ "file_path": "test.txt",
+ "start_line": float64(5),
+ "end_line": float64(2),
+ })
+ if err == nil {
+ t.Error("expected error for invalid line range")
+ }
+}
+
+func TestExecute_StartBeyondTotalLines(t *testing.T) {
+ dir := t.TempDir()
+ writeTestFile(t, dir, "short.txt", "one\n")
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ p := NewFileRead(fr)
+
+ _, err := p.Execute(context.Background(), map[string]any{
+ "file_path": "short.txt",
+ "start_line": float64(100),
+ "end_line": float64(200),
+ })
+ if err == nil {
+ t.Error("expected error for start beyond total lines")
+ }
+}
+
+func TestExecute_MissingFile(t *testing.T) {
+ fr := &FileReader{RepoDir: t.TempDir(), Mode: ModeWorkspace}
+ p := NewFileRead(fr)
+
+ _, err := p.Execute(context.Background(), map[string]any{"file_path": "missing.txt"})
+ if err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestExecute_CommitMode(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}
+ p := NewFileRead(fr)
+
+ got, err := p.Execute(context.Background(), map[string]any{"file_path": "hello.go"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "package main") {
+ t.Errorf("expected file content, got: %s", got)
+ }
+ if !strings.Contains(got, "IS_TRUNCATED: false") {
+ t.Error("expected IS_TRUNCATED: false")
+ }
+}
+
+func TestExecute_DefaultStartLine(t *testing.T) {
+ dir := t.TempDir()
+ writeTestFile(t, dir, "d.txt", "a\nb\nc\n")
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
+ p := NewFileRead(fr)
+
+ got, err := p.Execute(context.Background(), map[string]any{
+ "file_path": "d.txt",
+ "start_line": float64(0),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "LINE_RANGE: 1-") {
+ t.Errorf("expected default start_line=1, got: %s", got)
+ }
+}
+
+// setupTestRepo is already defined in code_search_test.go (same package)
+// getHeadCommit is already defined in code_search_test.go (same package)
+
func TestExecute_WithEndLine(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "c.txt", "a\nb\nc\nd\ne\n")
diff --git a/internal/tool/filereader_read_test.go b/internal/tool/filereader_read_test.go
index 74bffbc..96e2956 100644
--- a/internal/tool/filereader_read_test.go
+++ b/internal/tool/filereader_read_test.go
@@ -4,7 +4,10 @@ import (
"context"
"os"
"path/filepath"
+ "strings"
"testing"
+
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
)
func TestFileReader_Read_Workspace(t *testing.T) {
@@ -109,6 +112,90 @@ func TestFileReader_ReadLines_Workspace(t *testing.T) {
})
}
+func TestFileReader_Read_CommitMode(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}
+ got, err := fr.Read(context.Background(), "hello.go")
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+ if !strings.Contains(got, "package main") {
+ t.Errorf("Read() = %q, want containing 'package main'", got)
+ }
+ if !strings.Contains(got, "func Hello()") {
+ t.Errorf("Read() = %q, want containing 'func Hello()'", got)
+ }
+}
+
+func TestFileReader_Read_CommitMode_MissingFile(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}
+ _, err := fr.Read(context.Background(), "nonexistent.go")
+ if err == nil {
+ t.Error("expected error for missing file in commit mode")
+ }
+}
+
+func TestFileReader_Read_CommitMode_WithRunner(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+ runner := gitcmd.New(4)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner}
+ got, err := fr.Read(context.Background(), "hello.go")
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+ if !strings.Contains(got, "package main") {
+ t.Errorf("Read() = %q, want containing 'package main'", got)
+ }
+}
+
+func TestFileReader_Read_CommitMode_WithRunner_MissingFile(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+ runner := gitcmd.New(4)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner}
+ _, err := fr.Read(context.Background(), "nonexistent.go")
+ if err == nil {
+ t.Error("expected error for missing file in commit mode with runner")
+ }
+}
+
+func TestFileReader_ReadLines_CommitMode_WithRunner(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+ runner := gitcmd.New(4)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner}
+ lines, total, err := fr.ReadLines(context.Background(), "hello.go", 1, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 4 {
+ t.Errorf("totalLines = %d, want 4", total)
+ }
+ if len(lines) < 1 || lines[0] != "package main" {
+ t.Errorf("first line = %q, want %q", lines[0], "package main")
+ }
+}
+
+func TestFileReader_ReadLines_CommitMode_MissingFile(t *testing.T) {
+ dir := setupTestRepo(t)
+ commit := getHeadCommit(t, dir)
+
+ fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}
+ _, _, err := fr.ReadLines(context.Background(), "nonexistent.go", 1, 100)
+ if err == nil {
+ t.Error("expected error for missing file in commit mode")
+ }
+}
+
func TestFileReader_Read_SubdirectoryFile(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "src", "pkg")
diff --git a/internal/tool/response_message_test.go b/internal/tool/response_message_test.go
new file mode 100644
index 0000000..0fa89d6
--- /dev/null
+++ b/internal/tool/response_message_test.go
@@ -0,0 +1,33 @@
+package tool
+
+import "testing"
+
+func TestComplete(t *testing.T) {
+ cp := Complete()
+ if !cp.Completed {
+ t.Error("Complete() should set Completed=true")
+ }
+ if cp.Data != "" {
+ t.Errorf("Complete() Data = %q, want empty", cp.Data)
+ }
+}
+
+func TestOf(t *testing.T) {
+ cp := Of("hello")
+ if cp.Completed {
+ t.Error("Of() should set Completed=false")
+ }
+ if cp.Data != "hello" {
+ t.Errorf("Of() Data = %q, want %q", cp.Data, "hello")
+ }
+}
+
+func TestOf_Empty(t *testing.T) {
+ cp := Of("")
+ if cp.Completed {
+ t.Error("Of(\"\") should set Completed=false")
+ }
+ if cp.Data != "" {
+ t.Errorf("Of(\"\") Data = %q, want empty", cp.Data)
+ }
+}
diff --git a/internal/tool/stub_test.go b/internal/tool/stub_test.go
new file mode 100644
index 0000000..5c35d81
--- /dev/null
+++ b/internal/tool/stub_test.go
@@ -0,0 +1,48 @@
+package tool
+
+import (
+ "context"
+ "testing"
+)
+
+func TestStubProvider_Tool(t *testing.T) {
+ s := NewStub(FileRead)
+ if s.Tool() != FileRead {
+ t.Errorf("Tool() = %v, want FileRead", s.Tool())
+ }
+}
+
+func TestStubProvider_Execute(t *testing.T) {
+ s := NewStub(FileRead)
+ got, err := s.Execute(context.Background(), nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != NotAvailableMsg {
+ t.Errorf("Execute() = %q, want %q", got, NotAvailableMsg)
+ }
+}
+
+func TestBuiltinToolProvider(t *testing.T) {
+ called := false
+ fn := func(_ context.Context, args map[string]any) (string, error) {
+ called = true
+ return "result", nil
+ }
+
+ b := NewBuiltin(TaskDone, fn)
+ if b.Tool() != TaskDone {
+ t.Errorf("Tool() = %v, want TaskDone", b.Tool())
+ }
+
+ got, err := b.Execute(context.Background(), map[string]any{"key": "val"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !called {
+ t.Error("expected fn to be called")
+ }
+ if got != "result" {
+ t.Errorf("Execute() = %q, want %q", got, "result")
+ }
+}
From b109baf0c23c67b07a773023f1bc89a4bd4a4d4c Mon Sep 17 00:00:00 2001
From: zephyrq-z <122677241+zephyrq-z@users.noreply.github.com>
Date: Sat, 27 Jun 2026 11:06:38 +0800
Subject: [PATCH 15/87] Feature/rule link to files (#226)
* feat: support inline content and file path resolution for rule field
- rule field auto-detects: .md/.txt/.markdown ending = file path, otherwise inline
- file paths: project-relative first, then as absolute path
- safety: stat before read (512KB cap), extension whitelist, symlink resolution
- tightened heuristic: values with spaces treated as inline to avoid false positives
- guard against empty repoDir to avoid CWD-relative resolution
- 5-language README docs updated with file path usage and first-match-wins behavior
- 15 new unit tests covering all resolution branches
Closes #67
Supersedes #87
* update readme
* feat: update rule resolution logic to clear rules for missing or invalid files
* feat: update rule field description to clarify file path detection criteria
* feat: enhance rule file resolution to block path traversal and improve validation
* fix: clean up tryReadRuleFile and add missing blank line
- Add blank line between matchProjectRuleEntry and allowedRuleExts (Issue 1)
- Remove dead code '|| repoDir == ' in tryReadRuleFile (Issue 2)
- Remove unnecessary warning when repoDir is empty but path is absolute (Issue 3)
---
README.ja-JP.md | 39 ++
README.ko-KR.md | 39 ++
README.md | 39 ++
README.ru-RU.md | 39 ++
README.zh-CN.md | 39 ++
internal/config/rules/system_rules.go | 113 ++++++
internal/config/rules/system_rules_test.go | 403 +++++++++++++++++++++
7 files changed, 711 insertions(+)
diff --git a/README.ja-JP.md b/README.ja-JP.md
index 8998dd9..016b3cb 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -565,6 +565,45 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
- 各層の中では、ルールは宣言順に評価されます — 最初にマッチしたものが採用されます。
- ルールファイルが存在しない場合は、何も出力せずスキップされます。
+**`rule` フィールドはインラインコンテンツとファイルパスの両方をサポートします。** システムは次の順序で自動判別します:
+
+1. 値に改行が含まれる → **インラインコンテンツ**(複数行ルールがファイルパスと見なされることはありません)。
+2. 値が単一行で、スペースを含まず、`.md` / `.txt` / `.markdown` で終わる → **ファイルパス**。
+ - 絶対パス(`/` で始まる)はそのまま使用されます。
+ - 相対パスはプロジェクトルートで解決されます。パストラバーサル(例: `../../etc/passwd.md`)はブロックされます。見つからない場合は `[WARN]` を出力し、ルールはクリアされます(インラインへのフォールバックなし)。
+ - ファイルはバリデーションを通過する必要があります:ホワイトリスト拡張子、≤ 512 KB、シンボリックリンク解決後のターゲットもホワイトリスト拡張子であること。バリデーションに失敗した場合、ルールはクリアされます。
+3. それ以外 → **インラインコンテンツ**。
+
+```json
+{
+ "rules": [
+ {
+ "path": "**/*mapper*.xml",
+ "rule": "docs/sql-rules.md"
+ },
+ {
+ "path": "**/*.java",
+ "rule": "Always check for null safety and resource leaks"
+ },
+ {
+ "path": "**/*.go",
+ "rule": "shared/go-concurrency.md"
+ },
+ {
+ "path": "**/*.py",
+ "rule": "/Users/me/team-rules/python.md"
+ }
+ ]
+}
+```
+
+- `docs/sql-rules.md` — 相対パス、`/docs/sql-rules.md` から読み込み。
+- `Always check for null safety…` — インライン文字列、そのまま使用。
+- `shared/go-concurrency.md` — 相対パス、同様に解決。
+- `/Users/me/team-rules/python.md` — 絶対パス、そのまま使用。
+
+> 絶対パスはプロジェクト外のファイルにアクセスできますが、これは意図的な設計です。`rule.json` はメンテナが作成する信頼された入力のためです。共有ルールを共通パス(例:`/opt/company-rules/`)に置くことで、各プロジェクトへのコピーが不要になります。
+
### パスフィルタリング
ルールファイルでは `include` と `exclude` フィールドも使用でき、どのファイルをレビュー対象にするかを制御できます:
diff --git a/README.ko-KR.md b/README.ko-KR.md
index c0d4558..5ce4dbb 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -565,6 +565,45 @@ OCR은 네 계층의 priority chain으로 review rule을 해석합니다. 각
- 각 계층 안에서는 rule이 선언 순서대로 평가되며 첫 번째 match가 선택됩니다.
- rule file이 없으면 조용히 건너뜁니다.
+**`rule` 필드는 인라인 콘텐츠와 파일 경로를 모두 지원합니다.** 시스템이 다음 순서로 자동 판별합니다:
+
+1. 값에 줄바꿈이 포함된 경우 → **인라인 콘텐츠** (여러 줄 규칙은 파일 경로로 간주되지 않습니다).
+2. 값이 한 줄이고 공백이 없으며 `.md` / `.txt` / `.markdown`으로 끝나는 경우 → **파일 경로**.
+ - 절대 경로(`/`로 시작)는 그대로 사용됩니다.
+ - 상대 경로는 프로젝트 루트에서 확인합니다. 경로 탐색(예: `../../etc/passwd.md`)은 차단됩니다. 없으면 `[WARN]`을 출력하고 규칙이 지워집니다 (인라인으로 폴백 없음).
+ - 파일은 유효성 검사를 통과해야 합니다: 허용된 확장자, ≤ 512 KB, 심볼릭 링크 해석 후 대상도 허용된 확장자여야 합니다. 검증 실패 시 규칙이 지워집니다.
+3. 그 외의 경우 → **인라인 콘텐츠**.
+
+```json
+{
+ "rules": [
+ {
+ "path": "**/*mapper*.xml",
+ "rule": "docs/sql-rules.md"
+ },
+ {
+ "path": "**/*.java",
+ "rule": "Always check for null safety and resource leaks"
+ },
+ {
+ "path": "**/*.go",
+ "rule": "shared/go-concurrency.md"
+ },
+ {
+ "path": "**/*.py",
+ "rule": "/Users/me/team-rules/python.md"
+ }
+ ]
+}
+```
+
+- `docs/sql-rules.md` — 상대 경로, `/docs/sql-rules.md`에서 로드.
+- `Always check for null safety…` — 인라인 문자열, 그대로 사용.
+- `shared/go-concurrency.md` — 상대 경로, 동일하게 해결.
+- `/Users/me/team-rules/python.md` — 절대 경로, 그대로 사용.
+
+> 절대 경로는 프로젝트 외부 파일에 접근할 수 있으며, 이는 의도된 설계입니다. `rule.json`은 프로젝트 메인테이너가 작성하는 신뢰된 입력입니다. 팀은 공유 규칙을 공통 경로(예: `/opt/company-rules/`)에 두어 각 프로젝트에 복사할 필요가 없습니다.
+
## Configuration Reference
Config file: `~/.opencodereview/config.json`
diff --git a/README.md b/README.md
index b37e65c..a5b8946 100644
--- a/README.md
+++ b/README.md
@@ -570,6 +570,45 @@ Layers 1–3 share the same JSON format:
- Within each layer, rules are evaluated in declaration order — the first match wins.
- If a rule file does not exist, it is silently skipped.
+**The `rule` field supports both inline content and file paths.** The system auto-detects which one you mean:
+
+1. If the value contains newlines → **inline content** (multi-line rules are never file paths).
+2. If the value is a single line, contains no spaces, and ends with `.md` / `.txt` / `.markdown` → **file path**.
+ - Absolute paths (starting with `/`) are used directly.
+ - Relative paths are resolved against the project root. Path traversal (e.g. `../../etc/passwd.md`) is blocked. If not found, a `[WARN]` is emitted and the rule is cleared (no fallback to inline).
+ - The file must pass validation: whitelisted extension, ≤ 512 KB, and resolved symlink target must also be a whitelisted extension. If validation fails, the rule is cleared.
+3. Otherwise → **inline content**.
+
+```json
+{
+ "rules": [
+ {
+ "path": "**/*mapper*.xml",
+ "rule": "docs/sql-rules.md"
+ },
+ {
+ "path": "**/*.java",
+ "rule": "Always check for null safety and resource leaks"
+ },
+ {
+ "path": "**/*.go",
+ "rule": "shared/go-concurrency.md"
+ },
+ {
+ "path": "**/*.py",
+ "rule": "/Users/me/team-rules/python.md"
+ }
+ ]
+}
+```
+
+- `docs/sql-rules.md` — relative path, resolved from `/docs/sql-rules.md`.
+- `Always check for null safety…` — inline string, used directly.
+- `shared/go-concurrency.md` — relative path, same resolution.
+- `/Users/me/team-rules/python.md` — absolute path, used directly.
+
+> Absolute paths can access files outside the project directory — this is intentional. `rule.json` is authored by project maintainers, i.e. trusted input. Teams can store shared rules at a common path (e.g. `/opt/company-rules/`) instead of copying them into every project.
+
### Path Filtering
Rule files also support `include` and `exclude` fields to control which files enter the review scope:
diff --git a/README.ru-RU.md b/README.ru-RU.md
index 522aabd..8f74bf9 100644
--- a/README.ru-RU.md
+++ b/README.ru-RU.md
@@ -567,6 +567,45 @@ OCR разрешает правила ревью по цепочке приор
- Внутри каждого уровня правила проверяются в порядке объявления — побеждает первое совпадение.
- Если файл правил не существует, он молча пропускается.
+**Поле `rule` поддерживает как встроенный текст, так и пути к файлам.** Система определяет тип автоматически:
+
+1. Если значение содержит переносы строк → **встроенный текст** (многострочные правила никогда не считаются путями).
+2. Если значение — одна строка, без пробелов, и заканчивается на `.md` / `.txt` / `.markdown` → **путь к файлу**.
+ - Абсолютные пути (начинающиеся с `/`) используются напрямую.
+ - Относительные пути проверяются в корне проекта. Выход за пределы директории (например, `../../etc/passwd.md`) блокируется. Если не найдены — выводится `[WARN]` и правило очищается (без fallback на inline).
+ - Файл должен пройти проверку: допустимое расширение, ≤ 512 KB, цель симлинка также должна иметь допустимое расширение. При ошибке проверки правило очищается.
+3. Иначе → **встроенный текст**.
+
+```json
+{
+ "rules": [
+ {
+ "path": "**/*mapper*.xml",
+ "rule": "docs/sql-rules.md"
+ },
+ {
+ "path": "**/*.java",
+ "rule": "Always check for null safety and resource leaks"
+ },
+ {
+ "path": "**/*.go",
+ "rule": "shared/go-concurrency.md"
+ },
+ {
+ "path": "**/*.py",
+ "rule": "/Users/me/team-rules/python.md"
+ }
+ ]
+}
+```
+
+- `docs/sql-rules.md` — относительный путь, загружается из `/docs/sql-rules.md`.
+- `Always check for null safety…` — встроенная строка, используется напрямую.
+- `shared/go-concurrency.md` — относительный путь, аналогично.
+- `/Users/me/team-rules/python.md` — абсолютный путь, используется напрямую.
+
+> Абсолютные пути могут указывать на файлы вне директории проекта — это сделано намеренно. `rule.json` пишут мейнтейнеры проекта, это доверенный ввод. Команды могут хранить общие правила по единому пути (например, `/opt/company-rules/`) и не копировать их в каждый проект.
+
### Фильтрация путей
Файлы правил также поддерживают поля `include` и `exclude`, управляющие тем, какие файлы попадают в область ревью:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ba594d4..1d37593 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -555,6 +555,45 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
- 在每一层内,规则按声明顺序评估 —— 首次匹配生效。
- 如果规则文件不存在,将被静默跳过。
+**`rule` 字段同时支持内联内容和文件路径。**系统按以下顺序自动判断:
+
+1. 如果值包含换行 → **内联内容**(多行规则永远不会被当作文件路径)。
+2. 如果值是单行、不含空格、且以 `.md` / `.txt` / `.markdown` 结尾 → **文件路径**。
+ - 绝对路径(以 `/` 开头)直接使用。
+ - 相对路径在项目根目录下查找,路径穿越(如 `../../etc/passwd.md`)会被拦截。找不到则 `[WARN]` 并清空该规则(不会回退为内联)。
+ - 文件需通过安全校验:白名单扩展名、≤ 512 KB、symlink 解析后目标也必须是白名单扩展名。校验失败则清空该规则。
+3. 否则 → **内联内容**。
+
+```json
+{
+ "rules": [
+ {
+ "path": "**/*mapper*.xml",
+ "rule": "docs/sql-rules.md"
+ },
+ {
+ "path": "**/*.java",
+ "rule": "始终检查空值安全和资源泄漏"
+ },
+ {
+ "path": "**/*.go",
+ "rule": "shared/go-concurrency.md"
+ },
+ {
+ "path": "**/*.py",
+ "rule": "/Users/me/team-rules/python.md"
+ }
+ ]
+}
+```
+
+- `docs/sql-rules.md` — 相对路径,从 `/docs/sql-rules.md` 加载。
+- `始终检查空值安全…` — 内联字符串,直接使用。
+- `shared/go-concurrency.md` — 相对路径,同上。
+- `/Users/me/team-rules/python.md` — 绝对路径,直接使用。
+
+> 绝对路径可以访问项目目录之外的文件,这是有意为之的设计——`rule.json` 由项目维护者编写,属于受信输入。团队可将共享规则放在统一路径下(如 `/opt/company-rules/`),无需在各项目中复制。
+
### 路径过滤
规则文件同时支持 `include` 和 `exclude` 字段,用于控制哪些文件进入审查范围:
diff --git a/internal/config/rules/system_rules.go b/internal/config/rules/system_rules.go
index a752861..2da4d14 100644
--- a/internal/config/rules/system_rules.go
+++ b/internal/config/rules/system_rules.go
@@ -327,6 +327,7 @@ func loadGlobalRule() (*ProjectRule, error) {
if err := json.Unmarshal(data, &pr); err != nil {
return nil, fmt.Errorf("unmarshal global rule: %w", err)
}
+ resolveRuleEntries(pr.Rules, filepath.Dir(path))
return &pr, nil
}
@@ -339,6 +340,7 @@ func loadRuleFile(path string) (*ProjectRule, error) {
if err := json.Unmarshal(data, &pr); err != nil {
return nil, fmt.Errorf("unmarshal rule file %s: %w", path, err)
}
+ resolveRuleEntries(pr.Rules, filepath.Dir(path))
return &pr, nil
}
@@ -355,6 +357,7 @@ func loadProjectRule(repoDir string) (*ProjectRule, error) {
if err := json.Unmarshal(data, &pr); err != nil {
return nil, fmt.Errorf("unmarshal project rule: %w", err)
}
+ resolveRuleEntries(pr.Rules, repoDir)
return &pr, nil
}
@@ -438,3 +441,113 @@ func matchProjectRuleEntry(pr *ProjectRule, path string) *ProjectRuleEntry {
}
return nil
}
+
+// allowedRuleExts is the set of file extensions permitted for rule file references.
+var allowedRuleExts = map[string]bool{".md": true, ".txt": true, ".markdown": true}
+
+// looksLikeFilePath returns true when s is likely a file path (not inline content).
+// Heuristic: multi-line text is always inline; single-line text without spaces
+// ending in .md/.txt/.markdown is treated as a file path. Values containing spaces
+// (e.g. "Follow rules from team.md") are treated as inline to avoid false positives.
+func looksLikeFilePath(s string) bool {
+ if strings.Contains(s, "\n") {
+ return false
+ }
+ if strings.Contains(s, " ") {
+ return false
+ }
+ return allowedRuleExts[strings.ToLower(filepath.Ext(s))]
+}
+
+// resolveRuleEntries scans each entry's Rule field. When the value looks like a file
+// path, it reads the file content and replaces the Rule. Absolute paths are used
+// directly; relative paths are resolved against repoDir only. Multi-line and short
+// inline rules are left unchanged. If the file cannot be read, the Rule is cleared
+// (set to empty) and a warning is emitted.
+func resolveRuleEntries(entries []ProjectRuleEntry, repoDir string) {
+ for i := range entries {
+ e := &entries[i]
+ if strings.TrimSpace(e.Rule) == "" || !looksLikeFilePath(e.Rule) {
+ continue
+ }
+ if content := tryReadRuleFile(e.Rule, repoDir); content != nil {
+ e.Rule = *content
+ } else {
+ e.Rule = ""
+ }
+ }
+}
+
+// tryReadRuleFile attempts to read a rule file. Absolute paths are used directly.
+// Relative paths are resolved against repoDir and validated to stay within repoDir.
+// Returns nil when the file cannot be read safely or does not exist.
+func tryReadRuleFile(rule string, repoDir string) *string {
+ if repoDir == "" {
+ if !filepath.IsAbs(rule) {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot resolve relative rule path %q without a repo dir\n", rule)
+ return nil
+ }
+ }
+ if filepath.IsAbs(rule) {
+ content, err := readRuleFileSafe(rule)
+ if err == nil {
+ return &content
+ }
+ if os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file not found: %s\n", rule)
+ } else {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read rule file %s: %v\n", rule, err)
+ }
+ return nil
+ }
+
+ // Relative path: resolve against repoDir, validate no traversal.
+ resolved := filepath.Clean(filepath.Join(repoDir, rule))
+ cleanRepo := filepath.Clean(repoDir)
+ if !strings.HasPrefix(resolved, cleanRepo+string(os.PathSeparator)) {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file path escapes repo dir: %s\n", rule)
+ return nil
+ }
+
+ content, err := readRuleFileSafe(resolved)
+ if err == nil {
+ return &content
+ }
+ if os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file not found: %s\n", rule)
+ } else {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read rule file %s: %v\n", resolved, err)
+ }
+ return nil
+}
+
+// readRuleFileSafe reads and validates a rule file. It enforces extension whitelist
+// (.md / .txt / .markdown), a 512 KB size cap, and resolves symlinks before checking
+// the path. Symlinks are resolved first, then size is checked via Stat before reading.
+// Returns the trimmed content on success.
+func readRuleFileSafe(path string) (string, error) {
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ return "", err
+ }
+
+ if !allowedRuleExts[strings.ToLower(filepath.Ext(resolved))] {
+ return "", fmt.Errorf("unsupported extension %q, only .md/.txt/.markdown allowed", filepath.Ext(resolved))
+ }
+
+ const maxSize = 512 * 1024
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return "", err
+ }
+ if info.Size() > maxSize {
+ return "", fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), maxSize)
+ }
+
+ content, err := os.ReadFile(resolved)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimRight(string(content), "\n"), nil
+}
diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go
index 2d22a25..8d18fdf 100644
--- a/internal/config/rules/system_rules_test.go
+++ b/internal/config/rules/system_rules_test.go
@@ -945,3 +945,406 @@ func TestNewResolver_BraceExpansionInProjectRule(t *testing.T) {
})
}
}
+
+// ── resolveRuleEntries tests ──
+
+func TestResolveRuleEntries_BasicFile(t *testing.T) {
+ dir := t.TempDir()
+ ruleFile := filepath.Join(dir, "sql-rules.md")
+ if err := os.WriteFile(ruleFile, []byte("Check for SQL injection\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.xml", Rule: "sql-rules.md"},
+ {Path: "**/*.go", Rule: "Always check for nil"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "Check for SQL injection" {
+ t.Errorf("expected file content, got %q", entries[0].Rule)
+ }
+ if entries[1].Rule != "Always check for nil" {
+ t.Errorf("inline rule should not change, got %q", entries[1].Rule)
+ }
+}
+
+func TestResolveRuleEntries_MultiLineInline(t *testing.T) {
+ dir := t.TempDir()
+ // Create a file with the same name as the inline rule to make sure
+ // multi-line detection prevents file lookup.
+ if err := os.WriteFile(filepath.Join(dir, "security.md"), []byte("file content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.ts", Rule: "security.md\nBut this is multi-line\nso it should stay inline"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "security.md\nBut this is multi-line\nso it should stay inline" {
+ t.Errorf("multi-line rule should stay inline, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_MissingFile(t *testing.T) {
+ dir := t.TempDir()
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.xml", Rule: "nonexistent.md"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ // Missing file should clear the rule.
+ if entries[0].Rule != "" {
+ t.Errorf("missing file should clear rule, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_AbsolutePath(t *testing.T) {
+ dir := t.TempDir()
+ ruleFile := filepath.Join(dir, "my-rule.md")
+ if err := os.WriteFile(ruleFile, []byte("absolute rule content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Use an absolute path pointing to a file in a different directory.
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: ruleFile},
+ }
+ resolveRuleEntries(entries, "/some/other/repo")
+
+ if entries[0].Rule != "absolute rule content" {
+ t.Errorf("expected absolute file content, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_TooLarge(t *testing.T) {
+ dir := t.TempDir()
+ big := make([]byte, 513*1024)
+ for i := range big {
+ big[i] = 'a'
+ }
+ bigFile := filepath.Join(dir, "big.md")
+ if err := os.WriteFile(bigFile, big, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "big.md"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "" {
+ t.Errorf("oversized file should clear rule, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_RelativePath(t *testing.T) {
+ repoDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repoDir, "shared.md"), []byte("repo-level"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "shared.md"},
+ }
+ resolveRuleEntries(entries, repoDir)
+
+ if entries[0].Rule != "repo-level" {
+ t.Errorf("repo-level should win, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_EmptyRule(t *testing.T) {
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: ""},
+ {Path: "**/*.ts", Rule: " "},
+ {Path: "**/*.java", Rule: "\t\n"},
+ }
+ resolveRuleEntries(entries, "/tmp")
+
+ if entries[0].Rule != "" {
+ t.Errorf("empty rule should stay empty, got %q", entries[0].Rule)
+ }
+ if entries[1].Rule != " " {
+ t.Errorf("whitespace-only rule should stay unchanged, got %q", entries[1].Rule)
+ }
+ if entries[2].Rule != "\t\n" {
+ t.Errorf("whitespace+newline rule should stay unchanged, got %q", entries[2].Rule)
+ }
+}
+
+func TestResolveRuleEntries_SymlinkSafety(t *testing.T) {
+ dir := t.TempDir()
+ sensitiveFile := filepath.Join(dir, "secret.json")
+ if err := os.WriteFile(sensitiveFile, []byte("SECRET"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a symlink with .md extension pointing to a .json file.
+ // The extension check on the resolved path should reject .json.
+ symlinkPath := filepath.Join(dir, "evil.md")
+ if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "evil.md"},
+ }
+ resolveRuleEntries(entries, dir)
+ // The symlink target is .json, which is not in the whitelist.
+ // The rule should be cleared.
+ if entries[0].Rule != "" {
+ t.Errorf("symlink to non-whitelisted file should clear rule, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_TxtExtension(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "rules.txt"), []byte("rule from txt"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "rules.txt"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "rule from txt" {
+ t.Errorf(".txt should be accepted, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_MarkdownExtension(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "rules.markdown"), []byte("rule from markdown"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "rules.markdown"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "rule from markdown" {
+ t.Errorf(".markdown should be accepted, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_SubdirectoryPath(t *testing.T) {
+ dir := t.TempDir()
+ docsDir := filepath.Join(dir, "docs")
+ if err := os.MkdirAll(docsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(docsDir, "my-rule.md"), []byte("nested rule"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "docs/my-rule.md"},
+ }
+ resolveRuleEntries(entries, dir)
+
+ if entries[0].Rule != "nested rule" {
+ t.Errorf("subdirectory path should work, got %q", entries[0].Rule)
+ }
+}
+
+// ── looksLikeFilePath tests ──
+
+func TestLooksLikeFilePath_InlineContent(t *testing.T) {
+ tests := []string{
+ "Check for null pointers",
+ "Always validate input",
+ "security",
+ "xss",
+ }
+ for _, s := range tests {
+ if looksLikeFilePath(s) {
+ t.Errorf("looksLikeFilePath(%q) should be false", s)
+ }
+ }
+}
+
+func TestLooksLikeFilePath_MultiLine(t *testing.T) {
+ s := "line1\nline2\nline3"
+ if looksLikeFilePath(s) {
+ t.Errorf("multi-line should be false")
+ }
+}
+
+func TestLooksLikeFilePath_FileExtensions(t *testing.T) {
+ tests := []string{
+ "rules.md",
+ "doc.txt",
+ "doc.markdown",
+ "DOC.MD",
+ "path/to/file.md",
+ }
+ for _, s := range tests {
+ if !looksLikeFilePath(s) {
+ t.Errorf("looksLikeFilePath(%q) should be true", s)
+ }
+ }
+}
+
+func TestLooksLikeFilePath_WithSpaces(t *testing.T) {
+ // Values containing spaces are inline, not file paths.
+ tests := []string{
+ "Follow rules from team.md",
+ "Ensure output is in .md",
+ "use .txt format",
+ }
+ for _, s := range tests {
+ if looksLikeFilePath(s) {
+ t.Errorf("looksLikeFilePath(%q) should be false (contains space)", s)
+ }
+ }
+}
+
+func TestLooksLikeFilePath_PathWithoutExtension(t *testing.T) {
+ // Paths without .md/.txt/.markdown are NOT treated as file paths.
+ tests := []string{
+ "docs/security",
+ "shared/rules/go",
+ "Use HTTP/2 for all requests",
+ }
+ for _, s := range tests {
+ if looksLikeFilePath(s) {
+ t.Errorf("looksLikeFilePath(%q) should be false (no .md/.txt/.markdown)", s)
+ }
+ }
+}
+
+// ── readRuleFileSafe tests ──
+
+func TestReadRuleFileSafe_NormalFile(t *testing.T) {
+ dir := t.TempDir()
+ f := filepath.Join(dir, "test.md")
+ if err := os.WriteFile(f, []byte("hello world\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ content, err := readRuleFileSafe(f)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if content != "hello world" {
+ t.Errorf("expected 'hello world', got %q", content)
+ }
+}
+
+func TestReadRuleFileSafe_UnsupportedExt(t *testing.T) {
+ dir := t.TempDir()
+ f := filepath.Join(dir, "test.json")
+ if err := os.WriteFile(f, []byte("{}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := readRuleFileSafe(f)
+ if err == nil {
+ t.Fatal("expected error for .json")
+ }
+}
+
+func TestReadRuleFileSafe_TooLarge(t *testing.T) {
+ dir := t.TempDir()
+ f := filepath.Join(dir, "big.md")
+ big := make([]byte, 513*1024)
+ if err := os.WriteFile(f, big, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := readRuleFileSafe(f)
+ if err == nil {
+ t.Fatal("expected error for oversized file")
+ }
+}
+
+func TestReadRuleFileSafe_Missing(t *testing.T) {
+ _, err := readRuleFileSafe("/nonexistent/path.md")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+// ── path traversal tests ──
+
+func TestResolveRuleEntries_PathTraversalBlocked(t *testing.T) {
+ dir := t.TempDir()
+ // Create a file outside the repo dir to prove it is NOT read.
+ outside := filepath.Join(t.TempDir(), "outside.md")
+ if err := os.WriteFile(outside, []byte("should not be read\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed
+ {Path: "**/*.ts", Rule: "../outside.md"}, // relative traversal — blocked
+ }
+ resolveRuleEntries(entries, dir)
+
+ // Absolute path to outside is allowed (explicit design choice).
+ if entries[0].Rule != "should not be read" {
+ t.Errorf("absolute path to outside should be allowed, got %q", entries[0].Rule)
+ }
+ // Relative traversal should be blocked and rule cleared.
+ if entries[1].Rule != "" {
+ t.Errorf("relative traversal should be blocked, got %q", entries[1].Rule)
+ }
+}
+
+func TestResolveRuleEntries_EmptyRepoDirRelative(t *testing.T) {
+ // When repoDir is empty and rule is relative, it should be rejected.
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "rules.md"},
+ }
+ resolveRuleEntries(entries, "")
+
+ if entries[0].Rule != "" {
+ t.Errorf("relative path with empty repoDir should be rejected, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_EmptyRepoDirAbsolute(t *testing.T) {
+ dir := t.TempDir()
+ absFile := filepath.Join(dir, "abs.md")
+ if err := os.WriteFile(absFile, []byte("absolute content\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: absFile},
+ }
+ resolveRuleEntries(entries, "")
+
+ if entries[0].Rule != "absolute content" {
+ t.Errorf("absolute path with empty repoDir should work, got %q", entries[0].Rule)
+ }
+}
+
+func TestResolveRuleEntries_GlobalRuleFileResolution(t *testing.T) {
+ // Simulate loadGlobalRule: repoDir = filepath.Dir(~/.opencodereview/rule.json)
+ homeDir := t.TempDir()
+ t.Setenv("HOME", homeDir)
+
+ globalRuleDir := filepath.Join(homeDir, ".opencodereview")
+ if err := os.MkdirAll(globalRuleDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(globalRuleDir, "reusable.md"), []byte("global reusable rule\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := []ProjectRuleEntry{
+ {Path: "**/*.go", Rule: "reusable.md"},
+ }
+ // repoDir = ~/.opencodereview (where rule.json lives)
+ resolveRuleEntries(entries, globalRuleDir)
+
+ if entries[0].Rule != "global reusable rule" {
+ t.Errorf("global rule file should be resolved, got %q", entries[0].Rule)
+ }
+}
From f2a6f7c5c70f71a2cccc08590c3fd17dd23726c4 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 11:13:22 +0800
Subject: [PATCH 16/87] ci: add 80% statement coverage threshold check
Add coverage threshold enforcement in CI workflow and Makefile to satisfy
OpenSSF Best Practices Silver badge test_statement_coverage80 criterion.
---
.github/workflows/ci.yml | 16 ++++++++++++++--
Makefile | 16 ++++++++++++++--
internal/config/rules/system_rules_test.go | 2 +-
3 files changed, 29 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a36405c..ef44b2a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,8 +29,20 @@ jobs:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
- - name: Test
- run: go test -v -race -count=1 ./...
+ - name: Test with coverage
+ run: |
+ go test -v -race -count=1 -coverprofile=coverage.out ./...
+ go tool cover -func=coverage.out | grep total: | awk '{print $3}'
+
+ - name: Check coverage threshold
+ run: |
+ COVERAGE=$(go tool cover -func=coverage.out | grep total: | awk '{print $3}' | sed 's/%//')
+ echo "Total coverage: ${COVERAGE}%"
+ if awk "BEGIN {exit !($COVERAGE < 80)}"; then
+ echo "FAIL: Coverage ${COVERAGE}% is below 80% threshold"
+ exit 1
+ fi
+ echo "PASS: Coverage ${COVERAGE}% meets 80% threshold"
- name: Build
run: go build -o /dev/null ./cmd/opencodereview
diff --git a/Makefile b/Makefile
index bb83bc0..af0803f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build test clean run help fmt vet check \
+.PHONY: build test clean run help fmt vet check coverage \
build-all dist sha256sum version-info \
build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 \
build-windows-amd64 build-windows-arm64
@@ -36,8 +36,20 @@ PACKAGES := $(shell $(GO) list ./... | grep -v /extensions/)
test:
LC_ALL=C $(GO) test -v -race -count=1 $(PACKAGES)
+COVERAGE_THRESHOLD := 80
+
+coverage:
+ LC_ALL=C $(GO) test -count=1 -coverprofile=coverage.out $(PACKAGES)
+ $(GO) tool cover -func=coverage.out | grep total:
+ @COVERAGE=$$($(GO) tool cover -func=coverage.out | grep total: | awk '{print $$3}' | sed 's/%//'); \
+ if awk "BEGIN {exit !($$COVERAGE < $(COVERAGE_THRESHOLD))}"; then \
+ echo "FAIL: Coverage $${COVERAGE}% is below $(COVERAGE_THRESHOLD)% threshold"; \
+ exit 1; \
+ fi; \
+ echo "PASS: Coverage $${COVERAGE}% meets $(COVERAGE_THRESHOLD)% threshold"
+
clean:
- rm -rf $(DIST_DIR)
+ rm -rf $(DIST_DIR) coverage.out
run: build
$(DIST_DIR)/$(BINARY_NAME) --staged
diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go
index 8d18fdf..374e077 100644
--- a/internal/config/rules/system_rules_test.go
+++ b/internal/config/rules/system_rules_test.go
@@ -1281,7 +1281,7 @@ func TestResolveRuleEntries_PathTraversalBlocked(t *testing.T) {
}
entries := []ProjectRuleEntry{
- {Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed
+ {Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed
{Path: "**/*.ts", Rule: "../outside.md"}, // relative traversal — blocked
}
resolveRuleEntries(entries, dir)
From dde66f1dee6b5a81367f65584f47b12963214645 Mon Sep 17 00:00:00 2001
From: kite
Date: Sat, 27 Jun 2026 11:19:28 +0800
Subject: [PATCH 17/87] docs: update OpenSSF badge to Silver level
---
README.ja-JP.md | 2 +-
README.ko-KR.md | 2 +-
README.md | 2 +-
README.ru-RU.md | 2 +-
README.zh-CN.md | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.ja-JP.md b/README.ja-JP.md
index 016b3cb..45f0a8d 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -16,7 +16,7 @@
-
+
diff --git a/README.ko-KR.md b/README.ko-KR.md
index 5ce4dbb..3efd104 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -16,7 +16,7 @@
-
+
diff --git a/README.md b/README.md
index a5b8946..047b29c 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
-
+
diff --git a/README.ru-RU.md b/README.ru-RU.md
index 8f74bf9..72e1274 100644
--- a/README.ru-RU.md
+++ b/README.ru-RU.md
@@ -16,7 +16,7 @@
-
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1d37593..01446a6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
-
+
From 0d601eaf582eeb86569084b508d0dd982e14dd50 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 18:02:15 +0800
Subject: [PATCH 18/87] chore(deps): bump the go-dependencies group with 12
updates (#232)
Bumps the go-dependencies group with 12 updates:
| Package | From | To |
| --- | --- | --- |
| [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) | `2.0.3` | `2.0.4` |
| [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) | `1.47.0` | `1.52.0` |
| [github.com/openai/openai-go/v3](https://github.com/openai/openai-go) | `3.39.0` | `3.41.0` |
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/stdout/stdoutmetric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/stdout/stdouttrace](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/trace](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
Updates `charm.land/lipgloss/v2` from 2.0.3 to 2.0.4
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.3...v2.0.4)
Updates `github.com/anthropics/anthropic-sdk-go` from 1.47.0 to 1.52.0
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.47.0...v1.52.0)
Updates `github.com/openai/openai-go/v3` from 3.39.0 to 3.41.0
- [Release notes](https://github.com/openai/openai-go/releases)
- [Changelog](https://github.com/openai/openai-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-go/compare/v3.39.0...v3.41.0)
Updates `go.opentelemetry.io/otel` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/exporters/stdout/stdouttrace` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/metric` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/sdk` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/sdk/metric` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
Updates `go.opentelemetry.io/otel/trace` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)
---
updated-dependencies:
- dependency-name: charm.land/lipgloss/v2
dependency-version: 2.0.4
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: go-dependencies
- dependency-name: github.com/anthropics/anthropic-sdk-go
dependency-version: 1.52.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: github.com/openai/openai-go/v3
dependency-version: 3.41.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/stdout/stdoutmetric
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/stdout/stdouttrace
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/metric
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk/metric
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/trace
dependency-version: 1.44.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
go.mod | 38 ++++++++++++++--------------
go.sum | 78 ++++++++++++++++++++++++++++++----------------------------
2 files changed, 59 insertions(+), 57 deletions(-)
diff --git a/go.mod b/go.mod
index c6dcda1..3f48112 100644
--- a/go.mod
+++ b/go.mod
@@ -5,20 +5,20 @@ go 1.25.0
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
- charm.land/lipgloss/v2 v2.0.3
- github.com/anthropics/anthropic-sdk-go v1.47.0
+ charm.land/lipgloss/v2 v2.0.4
+ github.com/anthropics/anthropic-sdk-go v1.52.0
github.com/bmatcuk/doublestar/v4 v4.10.0
- github.com/openai/openai-go/v3 v3.39.0
+ github.com/openai/openai-go/v3 v3.41.0
github.com/pkoukk/tiktoken-go v0.1.8
- go.opentelemetry.io/otel v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
- go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
- go.opentelemetry.io/otel/metric v1.43.0
- go.opentelemetry.io/otel/sdk v1.43.0
- go.opentelemetry.io/otel/sdk/metric v1.43.0
- go.opentelemetry.io/otel/trace v1.43.0
+ go.opentelemetry.io/otel v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
+ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0
+ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0
+ go.opentelemetry.io/otel/metric v1.44.0
+ go.opentelemetry.io/otel/sdk v1.44.0
+ go.opentelemetry.io/otel/sdk/metric v1.44.0
+ go.opentelemetry.io/otel/trace v1.44.0
)
require (
@@ -39,7 +39,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
@@ -53,15 +53,15 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
- golang.org/x/net v0.52.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.35.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
- google.golang.org/grpc v1.80.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/go.sum b/go.sum
index 291ab0b..5085065 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
-charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
-charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
-github.com/anthropics/anthropic-sdk-go v1.47.0 h1:p1F48S/5UAGK3h2NzvZP8rqKnZqB7RkyYvOEM8dOEaQ=
-github.com/anthropics/anthropic-sdk-go v1.47.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
+charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
+charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
+github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
+github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
@@ -55,8 +55,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
@@ -65,8 +65,8 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/openai/openai-go/v3 v3.39.0 h1:WgLGgMOOdQDkZyo8YIhzUNXRXlEc+OJfU4EKP5Qp6AA=
-github.com/openai/openai-go/v3 v3.39.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/openai/openai-go/v3 v3.41.0 h1:9GkxcN02U5NG0WGdQjZ0cTSu/pMXEyzL2LfF0ruZCck=
+github.com/openai/openai-go/v3 v3.41.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
@@ -93,26 +93,28 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
-go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
-go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
-go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
-go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
-go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
-go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
-go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
-go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
-go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
+go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -121,22 +123,22 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
-google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
-google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
From 0881ad87b849ffee9faef3b7fb9cfb8b2f914f9c Mon Sep 17 00:00:00 2001
From: kite
Date: Mon, 29 Jun 2026 11:37:23 +0800
Subject: [PATCH 19/87] fix(ci): use --replace-all for git safe.directory to
prevent self-hosted runner failure
On self-hosted runners, _github_home/.gitconfig persists across jobs.
The ocr-review workflow used --add which accumulated multiple safe.directory
values over time. Once multiple values existed, other workflows using plain
git config (without --add/--replace-all) failed with "cannot overwrite
multiple values with a single value".
Unify all workflows to use --replace-all, which clears previous values and
writes exactly one entry regardless of prior state.
---
.github/workflows/ci.yml | 2 +-
.github/workflows/deploy-pages.yml | 2 +-
.github/workflows/ocr-review.yml | 2 +-
.github/workflows/release.yml | 6 +++---
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ef44b2a..31557d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
- name: Trust workspace
- run: git config --global safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Vet
run: go vet ./...
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 14e7822..e89f248 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v4
- name: Trust workspace
- run: git config --global safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Install dependencies
working-directory: pages
diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml
index 4ffcc22..e6bee99 100644
--- a/.github/workflows/ocr-review.yml
+++ b/.github/workflows/ocr-review.yml
@@ -73,7 +73,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Mark repository as safe directory
- run: git config --global --add safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Fetch PR head ref (ensures fork commits are available)
run: git fetch origin pull/${{ github.event.pull_request.number }}/head
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1536910..e11ee7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- name: Trust workspace
- run: git config --global safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Build
env:
@@ -73,7 +73,7 @@ jobs:
fetch-depth: 0
- name: Trust workspace
- run: git config --global safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Generate release notes from commits
id: notes
@@ -167,7 +167,7 @@ jobs:
- uses: actions/checkout@v4
- name: Trust workspace
- run: git config --global safe.directory '*'
+ run: git config --global --replace-all safe.directory '*'
- name: Install jq
run: apt-get update && apt-get install -y jq
From 08e7846a37f97dfacdac62ec2e61e8fffd0a68e6 Mon Sep 17 00:00:00 2001
From: "hezheng.lsw"
Date: Mon, 29 Jun 2026 14:42:43 +0800
Subject: [PATCH 20/87] refactor: cleanup design drafts, rename Chinese assets,
UI improvements (#239)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(pages): redesign landing page UI with i18n support
- Redesign all sections based on design file: Navbar, Hero, Highlights, UseCases, Features, Benchmark, QuickStart, Footer
- Add scroll fade-in animation with IntersectionObserver
- Implement full i18n (English/Chinese) with language toggle button
- Add SVG icons and image assets
- Style refinements: table borders, step containers, button styles, spacing
* feat(pages): redesign Docs page with i18n, responsive sidebar, and aligned copy interaction
- Redesign DocsPage UI based on reference HTML design
- Add i18n support for all Docs content (en/zh/ja)
- Fix right sidebar CONTENTS nav with fixed positioning
- Add container backgrounds (4% opacity, 16% border)
- Hide CONTENTS sidebar on mobile instead of alternative UI
- Align copy icon and toast interaction with QuickStart section
- Add new SVG icon assets for docs page
- Add Japanese language support
- Add ColorBends animated background component
- Add responsive hooks and FadeInSection animation
- Add Benchmark, Features, QuickStart standalone pages
- Update Navbar, Footer, and LandingPage components
* refactor: cleanup design drafts, rename Chinese assets, UI improvements
- Remove unused design draft folders (html-files, html-files (1), inpt, doc)
- Rename Chinese-named image files (容器-4_*.svg → provider-*.svg)
- Add language switcher icon to Navbar with dynamic character display
- Align Footer padding with Navbar (32px desktop, 16px mobile)
- Fix CONTENTS sidebar position on wide screens
- Add i18n support for Docs CONTENTS title
- Fix clipboard copy fallback for HTTP/LAN environments
- Adjust Toast padding, benchmark medal sizes
- Swap highlight stats positions (Adoption Rate ↔ Active Users)
- Update stat4 value to 25.10% and stat5 caption to Battle-tested
- Unify Hero buttons size with Navbar Get Started button
* refactor: rename icon assets with semantic English names and cleanup unused files
- Rename all svg_*.svg icons to semantic names based on usage context:
- icon-feature-*.svg for feature section icons
- icon-usecase-*.svg for use case section icons
- icon-chevron-*.svg, icon-copy.svg, icon-play.svg for UI controls
- icon-github.svg, icon-language.svg, icon-sort.svg, icon-terminal-prompt.svg
- Remove unused assets: 5 unreferenced svg icons, image_9e7821.png, provider-8.svg
- Remove debug screenshots: debug-screenshot.png, debug-wide.png
- Remove .vsix build artifact and add *.vsix to .gitignore
- Update all import paths in components accordingly
* fix: address PR review feedback - bugs and improvements
- Fix copy-paste bug: step3Label1 had wrong text in all 3 languages
- en: 'Interactive Setup (Recommended)' → 'Review Commands'
- zh: '交互式设置(推荐)' → '运行审查命令'
- ja: '対話式セットアップ(推奨)' → 'レビュー実行'
- Fix inconsistent indentation (4 spaces → 2 spaces) in i18n files
- Add .catch() handler for navigator.clipboard.writeText() in QuickStartSection
- Check document.execCommand('copy') return value before showing toast
- Extract fallbackCopy helper to deduplicate clipboard logic
- Remove unused useCallback import from HighlightsSection
- Move @types/three from dependencies to devDependencies
- Add i18n key 'hero.terminal' for hardcoded Terminal label
* fix: revert vscode .gitignore change and remove duplicate root SVGs
- Revert extensions/vscode/.gitignore to upstream state (remove *.vsix rule)
- Remove root-level brandicon.svg, claude code icon.svg, codex icon.svg
(identical copies already exist in pages/src/assets/images/ with proper names)
* fix: reorder highlights stats to match design reference
- Reorder stats: 20K+ Active Users → > 30% Adoption Rate → 1M+ Tasks → 1/9 Token Cost → 25.10% AACR-BENCH
- Update stat1 value from 30K+ to 20K+
- Sync all three languages (en/zh/ja)
* chore: remove unused debug screenshots
* chore: remove unused image_9e7821.png
---
pages/package.json | 4 +-
pages/src/App.tsx | 9 +-
pages/src/assets.d.ts | 10 +
pages/src/assets/icons/doc-check-circle.svg | 1 +
pages/src/assets/icons/doc-contents.svg | 1 +
pages/src/assets/icons/doc-copy.svg | 1 +
pages/src/assets/icons/doc-download.svg | 1 +
pages/src/assets/icons/doc-edit.svg | 1 +
pages/src/assets/icons/icon-chevron-down.svg | 1 +
pages/src/assets/icons/icon-chevron-right.svg | 1 +
pages/src/assets/icons/icon-copy.svg | 1 +
.../icons/icon-feature-architecture.svg | 1 +
.../assets/icons/icon-feature-compression.svg | 1 +
.../assets/icons/icon-feature-concurrent.svg | 1 +
.../assets/icons/icon-feature-multi-model.svg | 1 +
.../assets/icons/icon-feature-positioning.svg | 1 +
pages/src/assets/icons/icon-feature-rules.svg | 1 +
pages/src/assets/icons/icon-github.svg | 1 +
pages/src/assets/icons/icon-language.svg | 1 +
pages/src/assets/icons/icon-play.svg | 1 +
pages/src/assets/icons/icon-sort.svg | 1 +
.../src/assets/icons/icon-terminal-prompt.svg | 1 +
.../assets/icons/icon-usecase-developer.svg | 1 +
.../assets/icons/icon-usecase-platform-a.svg | 1 +
.../assets/icons/icon-usecase-platform-b.svg | 1 +
.../assets/icons/icon-usecase-platform-c.svg | 1 +
.../assets/icons/icon-usecase-researcher.svg | 1 +
pages/src/assets/icons/trophy-bronze.svg | 1 +
pages/src/assets/icons/trophy-gold.svg | 1 +
pages/src/assets/icons/trophy-silver.svg | 1 +
pages/src/assets/images/brandicon.svg | 1 +
pages/src/assets/images/icon-claude-code.svg | 1 +
pages/src/assets/images/icon-codex.svg | 1 +
pages/src/assets/images/provider-1.svg | 1 +
pages/src/assets/images/provider-2.svg | 1 +
pages/src/assets/images/provider-3.svg | 1 +
pages/src/assets/images/provider-4.svg | 1 +
pages/src/assets/images/provider-5.svg | 1 +
pages/src/assets/images/provider-6.svg | 1 +
pages/src/assets/images/provider-7.svg | 1 +
pages/src/components/BenchmarkSection.tsx | 703 ++++++-----------
pages/src/components/ColorBends.css | 11 +
pages/src/components/ColorBends.tsx | 321 ++++++++
pages/src/components/FadeInSection.tsx | 43 +
pages/src/components/FeaturesSection.tsx | 157 ++--
pages/src/components/Footer.tsx | 125 +++
pages/src/components/HeroSection.tsx | 495 ++++++++----
pages/src/components/HighlightsSection.tsx | 195 +++--
pages/src/components/LandingPage.tsx | 53 +-
pages/src/components/Navbar.tsx | 302 ++++---
pages/src/components/QuickStartSection.tsx | 319 ++++----
pages/src/components/UseCasesSection.tsx | 148 ++++
pages/src/hooks/useResponsive.ts | 28 +
pages/src/i18n/context.tsx | 5 +-
pages/src/i18n/en.ts | 121 ++-
pages/src/i18n/ja.ts | 184 +++++
pages/src/i18n/types.ts | 2 +-
pages/src/i18n/zh.ts | 241 +++---
pages/src/index.tsx | 5 +
pages/src/pages/BenchmarkPage.tsx | 19 +
pages/src/pages/DocsPage.tsx | 744 +++++++++---------
pages/src/pages/FeaturesPage.tsx | 39 +
pages/src/pages/QuickStartPage.tsx | 19 +
pages/src/styles/index.css | 443 ++---------
pages/webpack.config.js | 8 +-
65 files changed, 2790 insertions(+), 2000 deletions(-)
create mode 100644 pages/src/assets/icons/doc-check-circle.svg
create mode 100644 pages/src/assets/icons/doc-contents.svg
create mode 100644 pages/src/assets/icons/doc-copy.svg
create mode 100644 pages/src/assets/icons/doc-download.svg
create mode 100644 pages/src/assets/icons/doc-edit.svg
create mode 100644 pages/src/assets/icons/icon-chevron-down.svg
create mode 100644 pages/src/assets/icons/icon-chevron-right.svg
create mode 100644 pages/src/assets/icons/icon-copy.svg
create mode 100644 pages/src/assets/icons/icon-feature-architecture.svg
create mode 100644 pages/src/assets/icons/icon-feature-compression.svg
create mode 100644 pages/src/assets/icons/icon-feature-concurrent.svg
create mode 100644 pages/src/assets/icons/icon-feature-multi-model.svg
create mode 100644 pages/src/assets/icons/icon-feature-positioning.svg
create mode 100644 pages/src/assets/icons/icon-feature-rules.svg
create mode 100644 pages/src/assets/icons/icon-github.svg
create mode 100644 pages/src/assets/icons/icon-language.svg
create mode 100644 pages/src/assets/icons/icon-play.svg
create mode 100644 pages/src/assets/icons/icon-sort.svg
create mode 100644 pages/src/assets/icons/icon-terminal-prompt.svg
create mode 100644 pages/src/assets/icons/icon-usecase-developer.svg
create mode 100644 pages/src/assets/icons/icon-usecase-platform-a.svg
create mode 100644 pages/src/assets/icons/icon-usecase-platform-b.svg
create mode 100644 pages/src/assets/icons/icon-usecase-platform-c.svg
create mode 100644 pages/src/assets/icons/icon-usecase-researcher.svg
create mode 100644 pages/src/assets/icons/trophy-bronze.svg
create mode 100644 pages/src/assets/icons/trophy-gold.svg
create mode 100644 pages/src/assets/icons/trophy-silver.svg
create mode 100644 pages/src/assets/images/brandicon.svg
create mode 100644 pages/src/assets/images/icon-claude-code.svg
create mode 100644 pages/src/assets/images/icon-codex.svg
create mode 100644 pages/src/assets/images/provider-1.svg
create mode 100644 pages/src/assets/images/provider-2.svg
create mode 100644 pages/src/assets/images/provider-3.svg
create mode 100644 pages/src/assets/images/provider-4.svg
create mode 100644 pages/src/assets/images/provider-5.svg
create mode 100644 pages/src/assets/images/provider-6.svg
create mode 100644 pages/src/assets/images/provider-7.svg
create mode 100644 pages/src/components/ColorBends.css
create mode 100644 pages/src/components/ColorBends.tsx
create mode 100644 pages/src/components/FadeInSection.tsx
create mode 100644 pages/src/components/Footer.tsx
create mode 100644 pages/src/components/UseCasesSection.tsx
create mode 100644 pages/src/hooks/useResponsive.ts
create mode 100644 pages/src/i18n/ja.ts
create mode 100644 pages/src/pages/BenchmarkPage.tsx
create mode 100644 pages/src/pages/FeaturesPage.tsx
create mode 100644 pages/src/pages/QuickStartPage.tsx
diff --git a/pages/package.json b/pages/package.json
index 4ca474a..f6a0775 100644
--- a/pages/package.json
+++ b/pages/package.json
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.8.0"
+ "react-router-dom": "^6.8.0",
+ "three": "^0.185.0"
},
"overrides": {
"fast-uri": "^3.1.2"
@@ -22,6 +23,7 @@
"@babel/preset-typescript": "^7.23.3",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
+ "@types/three": "^0.185.0",
"autoprefixer": "^10.4.16",
"babel-loader": "^9.1.3",
"css-loader": "^6.8.1",
diff --git a/pages/src/App.tsx b/pages/src/App.tsx
index d5e58e0..7b2f761 100644
--- a/pages/src/App.tsx
+++ b/pages/src/App.tsx
@@ -1,13 +1,18 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import LandingPage from './components/LandingPage';
+import FeaturesPage from './pages/FeaturesPage';
+import BenchmarkPage from './pages/BenchmarkPage';
+import QuickStartPage from './pages/QuickStartPage';
import DocsPage from './pages/DocsPage';
const App: React.FC = () => {
return (
- } />
- } />
+ } />
+ } />
+ } />
+ } />
);
};
diff --git a/pages/src/assets.d.ts b/pages/src/assets.d.ts
index 3623b38..6ac3aff 100644
--- a/pages/src/assets.d.ts
+++ b/pages/src/assets.d.ts
@@ -2,3 +2,13 @@ declare module '*.svg' {
const src: string;
export default src;
}
+
+declare module '*.png' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.jpg' {
+ const src: string;
+ export default src;
+}
diff --git a/pages/src/assets/icons/doc-check-circle.svg b/pages/src/assets/icons/doc-check-circle.svg
new file mode 100644
index 0000000..151d981
--- /dev/null
+++ b/pages/src/assets/icons/doc-check-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/doc-contents.svg b/pages/src/assets/icons/doc-contents.svg
new file mode 100644
index 0000000..e240b3f
--- /dev/null
+++ b/pages/src/assets/icons/doc-contents.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/doc-copy.svg b/pages/src/assets/icons/doc-copy.svg
new file mode 100644
index 0000000..bc0531f
--- /dev/null
+++ b/pages/src/assets/icons/doc-copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/doc-download.svg b/pages/src/assets/icons/doc-download.svg
new file mode 100644
index 0000000..b06a6f3
--- /dev/null
+++ b/pages/src/assets/icons/doc-download.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/doc-edit.svg b/pages/src/assets/icons/doc-edit.svg
new file mode 100644
index 0000000..2db55e4
--- /dev/null
+++ b/pages/src/assets/icons/doc-edit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-chevron-down.svg b/pages/src/assets/icons/icon-chevron-down.svg
new file mode 100644
index 0000000..48f3a53
--- /dev/null
+++ b/pages/src/assets/icons/icon-chevron-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-chevron-right.svg b/pages/src/assets/icons/icon-chevron-right.svg
new file mode 100644
index 0000000..6ed70a2
--- /dev/null
+++ b/pages/src/assets/icons/icon-chevron-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-copy.svg b/pages/src/assets/icons/icon-copy.svg
new file mode 100644
index 0000000..8bc28fa
--- /dev/null
+++ b/pages/src/assets/icons/icon-copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-architecture.svg b/pages/src/assets/icons/icon-feature-architecture.svg
new file mode 100644
index 0000000..faaf445
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-architecture.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-compression.svg b/pages/src/assets/icons/icon-feature-compression.svg
new file mode 100644
index 0000000..69e7ac1
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-compression.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-concurrent.svg b/pages/src/assets/icons/icon-feature-concurrent.svg
new file mode 100644
index 0000000..dac64e2
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-concurrent.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-multi-model.svg b/pages/src/assets/icons/icon-feature-multi-model.svg
new file mode 100644
index 0000000..e47278a
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-multi-model.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-positioning.svg b/pages/src/assets/icons/icon-feature-positioning.svg
new file mode 100644
index 0000000..c1f115a
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-positioning.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-feature-rules.svg b/pages/src/assets/icons/icon-feature-rules.svg
new file mode 100644
index 0000000..4e6f6b0
--- /dev/null
+++ b/pages/src/assets/icons/icon-feature-rules.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-github.svg b/pages/src/assets/icons/icon-github.svg
new file mode 100644
index 0000000..d66421f
--- /dev/null
+++ b/pages/src/assets/icons/icon-github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-language.svg b/pages/src/assets/icons/icon-language.svg
new file mode 100644
index 0000000..69351c6
--- /dev/null
+++ b/pages/src/assets/icons/icon-language.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-play.svg b/pages/src/assets/icons/icon-play.svg
new file mode 100644
index 0000000..98ff051
--- /dev/null
+++ b/pages/src/assets/icons/icon-play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-sort.svg b/pages/src/assets/icons/icon-sort.svg
new file mode 100644
index 0000000..b7334e8
--- /dev/null
+++ b/pages/src/assets/icons/icon-sort.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-terminal-prompt.svg b/pages/src/assets/icons/icon-terminal-prompt.svg
new file mode 100644
index 0000000..4efb04f
--- /dev/null
+++ b/pages/src/assets/icons/icon-terminal-prompt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-usecase-developer.svg b/pages/src/assets/icons/icon-usecase-developer.svg
new file mode 100644
index 0000000..d6d0fef
--- /dev/null
+++ b/pages/src/assets/icons/icon-usecase-developer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-usecase-platform-a.svg b/pages/src/assets/icons/icon-usecase-platform-a.svg
new file mode 100644
index 0000000..5a5d8a6
--- /dev/null
+++ b/pages/src/assets/icons/icon-usecase-platform-a.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-usecase-platform-b.svg b/pages/src/assets/icons/icon-usecase-platform-b.svg
new file mode 100644
index 0000000..475c382
--- /dev/null
+++ b/pages/src/assets/icons/icon-usecase-platform-b.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-usecase-platform-c.svg b/pages/src/assets/icons/icon-usecase-platform-c.svg
new file mode 100644
index 0000000..66185fe
--- /dev/null
+++ b/pages/src/assets/icons/icon-usecase-platform-c.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/icon-usecase-researcher.svg b/pages/src/assets/icons/icon-usecase-researcher.svg
new file mode 100644
index 0000000..ed663dc
--- /dev/null
+++ b/pages/src/assets/icons/icon-usecase-researcher.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/trophy-bronze.svg b/pages/src/assets/icons/trophy-bronze.svg
new file mode 100644
index 0000000..8b0e476
--- /dev/null
+++ b/pages/src/assets/icons/trophy-bronze.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/trophy-gold.svg b/pages/src/assets/icons/trophy-gold.svg
new file mode 100644
index 0000000..1ec91f8
--- /dev/null
+++ b/pages/src/assets/icons/trophy-gold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/icons/trophy-silver.svg b/pages/src/assets/icons/trophy-silver.svg
new file mode 100644
index 0000000..5e62c3a
--- /dev/null
+++ b/pages/src/assets/icons/trophy-silver.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/brandicon.svg b/pages/src/assets/images/brandicon.svg
new file mode 100644
index 0000000..d4fe70e
--- /dev/null
+++ b/pages/src/assets/images/brandicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/icon-claude-code.svg b/pages/src/assets/images/icon-claude-code.svg
new file mode 100644
index 0000000..2f23cf2
--- /dev/null
+++ b/pages/src/assets/images/icon-claude-code.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/icon-codex.svg b/pages/src/assets/images/icon-codex.svg
new file mode 100644
index 0000000..4b48dad
--- /dev/null
+++ b/pages/src/assets/images/icon-codex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-1.svg b/pages/src/assets/images/provider-1.svg
new file mode 100644
index 0000000..ecc9fd1
--- /dev/null
+++ b/pages/src/assets/images/provider-1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-2.svg b/pages/src/assets/images/provider-2.svg
new file mode 100644
index 0000000..35b388d
--- /dev/null
+++ b/pages/src/assets/images/provider-2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-3.svg b/pages/src/assets/images/provider-3.svg
new file mode 100644
index 0000000..e892e26
--- /dev/null
+++ b/pages/src/assets/images/provider-3.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-4.svg b/pages/src/assets/images/provider-4.svg
new file mode 100644
index 0000000..ecf3ffd
--- /dev/null
+++ b/pages/src/assets/images/provider-4.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-5.svg b/pages/src/assets/images/provider-5.svg
new file mode 100644
index 0000000..992baf3
--- /dev/null
+++ b/pages/src/assets/images/provider-5.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-6.svg b/pages/src/assets/images/provider-6.svg
new file mode 100644
index 0000000..9038235
--- /dev/null
+++ b/pages/src/assets/images/provider-6.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/assets/images/provider-7.svg b/pages/src/assets/images/provider-7.svg
new file mode 100644
index 0000000..16120ef
--- /dev/null
+++ b/pages/src/assets/images/provider-7.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/components/BenchmarkSection.tsx b/pages/src/components/BenchmarkSection.tsx
index baf0096..9c4b1ef 100644
--- a/pages/src/components/BenchmarkSection.tsx
+++ b/pages/src/components/BenchmarkSection.tsx
@@ -1,497 +1,262 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from '../i18n';
-import { OcrIcon, ClaudeIcon, OpenAIIcon } from './icons';
+import { useResponsive } from '../hooks/useResponsive';
+import ocrIcon1 from '../assets/images/provider-1.svg';
+import ocrIcon2 from '../assets/images/provider-2.svg';
+import ocrIcon3 from '../assets/images/provider-3.svg';
+import ocrIcon4 from '../assets/images/provider-4.svg';
+import ocrIcon5 from '../assets/images/provider-5.svg';
+import ocrIcon6 from '../assets/images/provider-6.svg';
+import ocrIcon7 from '../assets/images/provider-7.svg';
+import claudeCodeIcon from '../assets/images/icon-claude-code.svg';
+import codexIcon from '../assets/images/icon-codex.svg';
+import sortIcon from '../assets/icons/icon-sort.svg';
+import trophyGold from '../assets/icons/trophy-gold.svg';
+import trophySilver from '../assets/icons/trophy-silver.svg';
+import trophyBronze from '../assets/icons/trophy-bronze.svg';
-interface BenchmarkEntry {
+const TROPHY_ICONS = [trophyGold, trophySilver, trophyBronze];
+
+interface BenchmarkRow {
model: string;
- company: string;
- sourceType: 'ocr' | 'cc' | 'codex';
- version: string;
- precision?: number;
- precisionDetail?: string;
- recall?: number;
- recallDetail?: string;
- f1?: number;
- avgTime?: string;
- avgInputToken?: string;
- avgOutputToken?: string;
- avgTotalToken?: string;
-}
-
-const OCR_VERSION = 'v1.3.1';
-const CC_VERSION = 'v2.1.169';
-const CODEX_VERSION = 'v0.140.0';
-
-const sourceColorMap: Record = {
- ocr: 'text-brand-400',
- cc: 'text-[#D97757]',
- codex: 'text-[#10a37f]',
-};
-const sourceNameMap: Record = {
- ocr: 'Open Code Review',
- cc: 'Claude Code',
- codex: 'Codex',
-};
-
-const benchmarkData: BenchmarkEntry[] = [
- {
- model: 'Claude-4.6-Opus',
- company: 'Anthropic',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 33.90,
- precisionDetail: '301/889',
- recall: 20.00,
- recallDetail: '301/1505',
- f1: 25.10,
- avgTime: '1m23s',
- avgInputToken: '375K',
- avgOutputToken: '10K',
- avgTotalToken: '385K',
- },
- {
- model: 'Qwen3.7-Max',
- company: 'Alibaba',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 25.20,
- precisionDetail: '276/1096',
- recall: 18.30,
- recallDetail: '276/1505',
- f1: 21.20,
- avgTime: '4m41s',
- avgInputToken: '587K',
- avgOutputToken: '38K',
- avgTotalToken: '625K',
- },
- {
- model: 'GPT-5.5',
- company: 'OpenAI',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 32.10,
- precisionDetail: '234/728',
- recall: 15.50,
- recallDetail: '234/1505',
- f1: 21.00,
- avgTime: '2m51s',
- avgInputToken: '409K',
- avgOutputToken: '13K',
- avgTotalToken: '422K',
- },
- {
- model: 'Claude-4.8-Opus',
- company: 'Anthropic',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 37.80,
- precisionDetail: '176/465',
- recall: 11.70,
- recallDetail: '176/1505',
- f1: 17.90,
- avgTime: '1m6s',
- avgInputToken: '342K',
- avgOutputToken: '11K',
- avgTotalToken: '352K',
- },
- {
- model: 'Deepseek-V4-Pro',
- company: 'DeepSeek',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 30.60,
- precisionDetail: '191/624',
- recall: 12.70,
- recallDetail: '191/1505',
- f1: 17.90,
- avgTime: '6m28s',
- avgInputToken: '350K',
- avgOutputToken: '44K',
- avgTotalToken: '394K',
- },
- {
- model: 'GLM-5.1',
- company: 'Zhipu AI',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 28.90,
- precisionDetail: '237/820',
- recall: 15.70,
- recallDetail: '237/1505',
- f1: 20.40,
- avgTime: '4m11s',
- avgInputToken: '707K',
- avgOutputToken: '36K',
- avgTotalToken: '743K',
- },
- {
- model: 'GLM-5.2',
- company: 'Zhipu AI',
- sourceType: 'ocr',
- version: OCR_VERSION,
- precision: 32.30,
- precisionDetail: '239/741',
- recall: 15.90,
- recallDetail: '239/1505',
- f1: 21.30,
- avgTime: '7m58s',
- avgInputToken: '624K',
- avgOutputToken: '58K',
- avgTotalToken: '682K',
- },
- {
- model: 'Claude-4.6-Opus',
- company: 'Anthropic',
- sourceType: 'cc',
- version: CC_VERSION,
- precision: 7.23,
- precisionDetail: '435/5980',
- recall: 28.90,
- recallDetail: '435/1505',
- f1: 11.57,
- avgTime: '13m6s',
- avgInputToken: '5603K',
- avgOutputToken: '60K',
- avgTotalToken: '5664K',
- },
- {
- model: 'Qwen3.7-Max',
- company: 'Alibaba',
- sourceType: 'cc',
- version: CC_VERSION,
- precision: 8.23,
- precisionDetail: '351/4260',
- recall: 23.37,
- recallDetail: '351/1505',
- f1: 12.17,
- avgTime: '8m6s',
- avgInputToken: '5108K',
- avgOutputToken: '44K',
- avgTotalToken: '5153K',
- },
- {
- model: 'Claude-4.8-Opus',
- company: 'Anthropic',
- sourceType: 'cc',
- version: CC_VERSION,
- precision: 15.93,
- precisionDetail: '191/1200',
- recall: 12.70,
- recallDetail: '191/1505',
- f1: 14.13,
- avgTime: '5m38s',
- avgInputToken: '2,039K',
- avgOutputToken: '23K',
- avgTotalToken: '2,062K',
- },
- {
- model: 'Deepseek-V4-Pro',
- company: 'DeepSeek',
- sourceType: 'cc',
- version: CC_VERSION,
- precision: 8.27,
- precisionDetail: '243/2945',
- recall: 16.13,
- recallDetail: '243/1505',
- f1: 10.93,
- avgTime: '14m24s',
- avgInputToken: '5389K',
- avgOutputToken: '60K',
- avgTotalToken: '5450K',
- },
- {
- model: 'GLM-5.1',
- company: 'Zhipu AI',
- sourceType: 'cc',
- version: CC_VERSION,
- precision: 8.37,
- precisionDetail: '313/3742',
- recall: 20.80,
- recallDetail: '313/1505',
- f1: 11.93,
- avgTime: '14m10s',
- avgInputToken: '3,998K',
- avgOutputToken: '39K',
- avgTotalToken: '4,038K',
- },
- {
- model: 'GPT-5.5',
- company: 'OpenAI',
- sourceType: 'codex',
- version: CODEX_VERSION,
- precision: 27.82,
- precisionDetail: '74/266',
- recall: 4.92,
- recallDetail: '74/1505',
- f1: 8.36,
- avgTime: '2m58s',
- avgInputToken: '520K',
- avgOutputToken: '5K',
- avgTotalToken: '525K',
- },
-];
-
-const medalIcons: Record = {
- gold: '🥇',
- silver: '🥈',
- bronze: '🥉',
-};
-
-function computeMedals(
- entries: BenchmarkEntry[],
- field: 'precision' | 'recall'
-): Map {
- const indexed = entries
- .map((e, i) => ({ index: i, value: e[field] }))
- .filter((e): e is { index: number; value: number } => e.value !== undefined)
- .sort((a, b) => b.value - a.value);
-
- const medals = new Map();
- const types = ['gold', 'silver', 'bronze'];
- indexed.slice(0, 3).forEach((item, i) => {
- medals.set(item.index, types[i]);
- });
- return medals;
+ provider: string;
+ source: string;
+ sourceIcon?: string;
+ f1: string;
+ f1Value: number;
+ precision: string;
+ precisionValue: number;
+ precisionDetail: string;
+ recall: string;
+ recallValue: number;
+ recallDetail: string;
+ avgTime: string;
+ avgToken: string;
+ tokenDetail: string;
+ precisionMedal?: number; // 0=gold, 1=silver, 2=bronze
+ recallMedal?: number; // 0=gold, 1=silver, 2=bronze
}
type SortField = 'f1' | 'precision' | 'recall';
+type SortOrder = 'desc' | 'asc';
+
+const rawRows: BenchmarkRow[] = [
+ { model: 'Claude-4.6-Opus', provider: 'Anthropic', source: 'Open Code Review', sourceIcon: ocrIcon1, f1: '25.10%', f1Value: 25.10, precision: '33.90%', precisionValue: 33.90, precisionDetail: '301/889', recall: '20.00%', recallValue: 20.00, recallDetail: '301/1505', avgTime: '1m23s', avgToken: '385K', tokenDetail: '375K / 10K', precisionMedal: 1 },
+ { model: 'GLM-5.2', provider: 'Zhipu AI', source: 'Open Code Review', sourceIcon: ocrIcon2, f1: '21.30%', f1Value: 21.30, precision: '32.30%', precisionValue: 32.30, precisionDetail: '239/741', recall: '15.90%', recallValue: 15.90, recallDetail: '239/1505', avgTime: '7m58s', avgToken: '682K', tokenDetail: '624K / 58K', precisionMedal: 2 },
+ { model: 'Qwen3.7-Max', provider: 'Alibaba', source: 'Open Code Review', sourceIcon: ocrIcon3, f1: '21.20%', f1Value: 21.20, precision: '25.20%', precisionValue: 25.20, precisionDetail: '276/1096', recall: '18.30%', recallValue: 18.30, recallDetail: '276/1505', avgTime: '4m41s', avgToken: '625K', tokenDetail: '587K / 38K' },
+ { model: 'GPT-5.5', provider: 'OpenAI', source: 'Open Code Review', sourceIcon: ocrIcon4, f1: '21.00%', f1Value: 21.00, precision: '32.10%', precisionValue: 32.10, precisionDetail: '234/728', recall: '15.50%', recallValue: 15.50, recallDetail: '234/1505', avgTime: '2m51s', avgToken: '422K', tokenDetail: '409K / 13K' },
+ { model: 'GLM-5.1', provider: 'Zhipu AI', source: 'Open Code Review', sourceIcon: ocrIcon5, f1: '20.40%', f1Value: 20.40, precision: '28.90%', precisionValue: 28.90, precisionDetail: '237/820', recall: '15.70%', recallValue: 15.70, recallDetail: '237/1505', avgTime: '4m11s', avgToken: '743K', tokenDetail: '707K / 36K' },
+ { model: 'Claude-4.8-Opus', provider: 'Anthropic', source: 'Open Code Review', sourceIcon: ocrIcon6, f1: '17.90%', f1Value: 17.90, precision: '37.80%', precisionValue: 37.80, precisionDetail: '176/465', recall: '11.70%', recallValue: 11.70, recallDetail: '176/1505', avgTime: '1m6s', avgToken: '352K', tokenDetail: '342K / 11K', precisionMedal: 0 },
+ { model: 'Deepseek-V4-Pro', provider: 'DeepSeek', source: 'Open Code Review', sourceIcon: ocrIcon7, f1: '17.90%', f1Value: 17.90, precision: '30.60%', precisionValue: 30.60, precisionDetail: '191/624', recall: '12.70%', recallValue: 12.70, recallDetail: '191/1505', avgTime: '6m28s', avgToken: '394K', tokenDetail: '350K / 44K' },
+ { model: 'Claude-4.8-Opus', provider: 'Anthropic', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '14.13%', f1Value: 14.13, precision: '15.93%', precisionValue: 15.93, precisionDetail: '191/1200', recall: '12.70%', recallValue: 12.70, recallDetail: '191/1505', avgTime: '5m38s', avgToken: '2,062K', tokenDetail: '2,039K / 23K' },
+ { model: 'Qwen3.7-Max', provider: 'Alibaba', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '12.17%', f1Value: 12.17, precision: '8.23%', precisionValue: 8.23, precisionDetail: '351/4260', recall: '23.37%', recallValue: 23.37, recallDetail: '351/1505', avgTime: '8m6s', avgToken: '5,153K', tokenDetail: '5,108K / 44K', recallMedal: 1 },
+ { model: 'GLM-5.1', provider: 'Zhipu AI', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '11.93%', f1Value: 11.93, precision: '8.37%', precisionValue: 8.37, precisionDetail: '313/3742', recall: '20.80%', recallValue: 20.80, recallDetail: '313/1505', avgTime: '14m10s', avgToken: '4,038K', tokenDetail: '3,998K / 39K', recallMedal: 2 },
+ { model: 'Claude-4.6-Opus', provider: 'Anthropic', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '11.57%', f1Value: 11.57, precision: '7.23%', precisionValue: 7.23, precisionDetail: '435/5980', recall: '28.90%', recallValue: 28.90, recallDetail: '435/1505', avgTime: '13m6s', avgToken: '5,664K', tokenDetail: '5,603K / 60K', recallMedal: 0 },
+ { model: 'Deepseek-V4-Pro', provider: 'DeepSeek', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '10.93%', f1Value: 10.93, precision: '8.27%', precisionValue: 8.27, precisionDetail: '243/2945', recall: '16.13%', recallValue: 16.13, recallDetail: '243/1505', avgTime: '14m24s', avgToken: '5,450K', tokenDetail: '5,389K / 60K' },
+ { model: 'GPT-5.5', provider: 'OpenAI', source: 'Codex', sourceIcon: codexIcon, f1: '8.36%', f1Value: 8.36, precision: '27.82%', precisionValue: 27.82, precisionDetail: '74/266', recall: '4.92%', recallValue: 4.92, recallDetail: '74/1505', avgTime: '2m58s', avgToken: '525K', tokenDetail: '520K / 5K' },
+];
+
+const MEDAL_RANKS = ['🥇', '🥈', '🥉'];
const BenchmarkSection: React.FC = () => {
- const [hoveredRow, setHoveredRow] = useState(null);
- const [sortField, setSortField] = useState('f1');
const { t } = useTranslation();
+ const { isMobile, isTablet } = useResponsive();
+ const [sortField, setSortField] = useState('f1');
+ const [sortOrder, setSortOrder] = useState('desc');
- const sortedData = [...benchmarkData].sort((a, b) => {
- const av = a[sortField];
- const bv = b[sortField];
- if (av == null && bv == null) return 0;
- if (av == null) return 1;
- if (bv == null) return -1;
- return bv - av;
- });
+ const sortedRows = useMemo(() => {
+ const key = `${sortField}Value` as keyof BenchmarkRow;
+ const sorted = [...rawRows].sort((a, b) => {
+ const aVal = a[key] as number;
+ const bVal = b[key] as number;
+ return sortOrder === 'desc' ? bVal - aVal : aVal - bVal;
+ });
+ return sorted;
+ }, [sortField, sortOrder]);
- const precisionMedals = computeMedals(sortedData, 'precision');
- const recallMedals = computeMedals(sortedData, 'recall');
-
- const ranks = new Map();
- let rank = 0;
- sortedData.forEach((entry, index) => {
- if (entry[sortField] != null) {
- rank++;
- ranks.set(index, rank);
+ const handleSort = useCallback((field: SortField) => {
+ if (sortField === field) {
+ setSortOrder(prev => prev === 'desc' ? 'asc' : 'desc');
+ } else {
+ setSortField(field);
+ setSortOrder('desc');
}
- });
+ }, [sortField]);
+
+ const sortableHeaders: { key: string; label: string; field?: SortField }[] = [
+ { key: 'rank', label: t('benchmark.colRank') },
+ { key: 'model', label: t('benchmark.colModel') },
+ { key: 'source', label: t('benchmark.colSource') },
+ { key: 'f1', label: t('benchmark.colF1'), field: 'f1' },
+ { key: 'precision', label: t('benchmark.colPrecision'), field: 'precision' },
+ { key: 'recall', label: t('benchmark.colRecall'), field: 'recall' },
+ { key: 'avgTime', label: t('benchmark.colAvgTime') },
+ { key: 'avgToken', label: t('benchmark.colAvgToken') },
+ ];
return (
-
-
-
-
-
-
-
-
- {t('benchmark.sectionLabel')}
-
-
- {t('benchmark.title')}
-
-
- {t('benchmark.subtitlePreRepos')}
- 50
- {t('benchmark.subtitlePrePRs')}
- 200
- {t('benchmark.subtitlePreLangs')}
- 10
- {t('benchmark.subtitleEnd')}
-
-
-
- {/* Legend */}
-
-
-
- Open Code Review · {OCR_VERSION}
-
-
-
- Claude Code · {CC_VERSION} · /code-review
-
-
-
- Codex · {CODEX_VERSION} · /review
-
-
-
- {/* Table */}
-
- {/* Header */}
-
-
{t('benchmark.colRank')}
-
{t('benchmark.colModel')}
-
{t('benchmark.colSource')}
- {(['f1', 'precision', 'recall'] as const).map((field) => {
- const labels: Record
= {
- f1: 'F1',
- precision: t('benchmark.colPrecision'),
- recall: t('benchmark.colRecall'),
- };
- return (
- setSortField(field)}
- >
- {labels[field]}
- ▼
-
- );
- })}
- {t('benchmark.colAvgTime')}
- {t('benchmark.colAvgToken')}
-
-
- {/* Rows */}
- {sortedData.map((entry, index) => {
- const entryRank = ranks.get(index);
- const hasData = entry.f1 != null;
- const pMedal = precisionMedals.get(index);
- const rMedal = recallMedals.get(index);
+
+
+ {/* Header */}
+
+
+ {t('benchmark.sectionLabel')}
+
+
+ {t('benchmark.title')}
+
+
+ {t('benchmark.subtitle')}
+
+
+ {/* Table */}
+
+ {/* Table Header */}
+
+ {sortableHeaders.map((col, i) => {
+ const isActive = col.field === sortField;
+ const isSortable = !!col.field;
return (
setHoveredRow(index)}
- onMouseLeave={() => setHoveredRow(null)}
+ key={col.key}
+ onClick={isSortable ? () => handleSort(col.field!) : undefined}
+ style={{
+ width: i === 0 ? 120 : i === 1 ? 200 : i === 2 ? 200 : undefined,
+ flex: i > 2 ? 1 : undefined,
+ height: 44,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 4,
+ padding: i === 0 ? '10px 20px' : '10px 12px',
+ cursor: isSortable ? 'pointer' : 'default',
+ userSelect: 'none',
+ }}
>
- {/* Rank */}
-
- {entryRank != null ? (
-
- {entryRank <= 3 ? medalIcons[['gold', 'silver', 'bronze'][entryRank - 1]] : (
- {entryRank}
- )}
-
- ) : (
-
-
-
- )}
-
-
- {/* Model + Company */}
-
-
{entry.model}
-
{entry.company}
-
-
- {/* Source */}
-
- {entry.sourceType === 'ocr' && }
- {entry.sourceType === 'cc' && }
- {entry.sourceType === 'codex' && }
-
- {sourceNameMap[entry.sourceType]}
-
-
-
- {/* F1 */}
-
- {entry.f1 != null ? (
-
- {entry.f1.toFixed(2)}%
-
- ) : (
-
-
- Running
-
- )}
-
-
- {/* Precision with medal */}
-
- {entry.precision != null ? (
-
-
- {entry.precision.toFixed(2)}%
-
- {pMedal ? medalIcons[pMedal] : ''}
-
-
- {entry.precisionDetail && (
-
{entry.precisionDetail}
- )}
-
- ) : (
-
- )}
-
-
- {/* Recall with medal */}
-
- {entry.recall != null ? (
-
-
- {entry.recall.toFixed(2)}%
-
- {rMedal ? medalIcons[rMedal] : ''}
-
-
- {entry.recallDetail && (
-
{entry.recallDetail}
- )}
-
- ) : (
-
- )}
-
-
- {/* Avg Time */}
-
- {entry.avgTime ? (
-
{entry.avgTime}
- ) : (
-
- )}
-
-
- {/* Avg Token */}
-
- {entry.avgTotalToken ? (
-
-
{entry.avgTotalToken}
- {entry.avgInputToken && (
-
{entry.avgInputToken} / {entry.avgOutputToken}
- )}
-
- ) : (
-
- )}
-
+
+ {col.label}
+
+ {isSortable && (
+

+ )}
);
})}
+ {/* Table Rows */}
+ {sortedRows.map((row, idx) => {
+ const bgOpacity = idx === 0 ? 0.25 : idx === 1 ? 0.18 : idx === 2 ? 0.1 : 0;
+ const rankDisplay = idx < 3 ? MEDAL_RANKS[idx] : String(idx + 1);
+ return (
+
0 ? `rgba(43,222,94,${bgOpacity})` : 'transparent',
+ borderStyle: 'solid',
+ borderColor: 'rgba(255,255,255,0.16)',
+ borderTopWidth: 0,
+ borderBottomWidth: idx === sortedRows.length - 1 ? 0 : 1,
+ borderRightWidth: 0,
+ borderLeftWidth: 0,
+ overflow: 'hidden',
+ }}
+ >
+ {/* Rank */}
+
+
+ {rankDisplay}
+
+
+ {/* Model */}
+
+ {row.model}
+ {row.provider}
+
+ {/* Source */}
+
+ {row.sourceIcon &&

}
+
{row.source}
+
+ {/* F1 */}
+
+
+ {row.f1}
+
+
+ {/* Precision */}
+
+
+
+ {row.precision}
+
+ {row.precisionMedal !== undefined &&

}
+
+
{row.precisionDetail}
+
+ {/* Recall */}
+
+
+
+ {row.recall}
+
+ {row.recallMedal !== undefined &&

}
+
+
{row.recallDetail}
+
+ {/* Avg Time */}
+
+ {row.avgTime}
+
+ {/* Avg Token */}
+
+ {row.avgToken}
+ {row.tokenDetail}
+
+
+ );
+ })}
-
+ {/* Footer note */}
+
+ Open Code Review · v1.3.1 | Claude Code · v2.1.169 · /code-review | Codex · v0.140.0 · /review
+
+
);
};
diff --git a/pages/src/components/ColorBends.css b/pages/src/components/ColorBends.css
new file mode 100644
index 0000000..b546eb2
--- /dev/null
+++ b/pages/src/components/ColorBends.css
@@ -0,0 +1,11 @@
+.color-bends-container {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.color-bends-container canvas {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+}
diff --git a/pages/src/components/ColorBends.tsx b/pages/src/components/ColorBends.tsx
new file mode 100644
index 0000000..7449fb0
--- /dev/null
+++ b/pages/src/components/ColorBends.tsx
@@ -0,0 +1,321 @@
+import React, { useEffect, useRef } from 'react';
+import * as THREE from 'three';
+import './ColorBends.css';
+
+const MAX_COLORS = 8;
+
+const frag = `
+#define MAX_COLORS ${MAX_COLORS}
+uniform vec2 uCanvas;
+uniform float uTime;
+uniform float uSpeed;
+uniform vec2 uRot;
+uniform int uColorCount;
+uniform vec3 uColors[MAX_COLORS];
+uniform int uTransparent;
+uniform float uScale;
+uniform float uFrequency;
+uniform float uWarpStrength;
+uniform vec2 uPointer;
+uniform float uMouseInfluence;
+uniform float uParallax;
+uniform float uNoise;
+uniform int uIterations;
+uniform float uIntensity;
+uniform float uBandWidth;
+varying vec2 vUv;
+
+void main() {
+ float t = uTime * uSpeed;
+ vec2 p = vUv * 2.0 - 1.0;
+ p += uPointer * uParallax * 0.1;
+ vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x);
+ vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y);
+ q /= max(uScale, 0.0001);
+ q /= 0.5 + 0.2 * dot(q, q);
+ q += 0.2 * cos(t) - 7.56;
+ vec2 toward = (uPointer - rp);
+ q += toward * uMouseInfluence * 0.2;
+
+ for (int j = 0; j < 5; j++) {
+ if (j >= uIterations - 1) break;
+ vec2 rr = sin(1.5 * (q.yx * uFrequency) + 2.0 * cos(q * uFrequency));
+ q += (rr - q) * 0.15;
+ }
+
+ vec3 col = vec3(0.0);
+ float a = 1.0;
+
+ if (uColorCount > 0) {
+ vec2 s = q;
+ vec3 sumCol = vec3(0.0);
+ float cover = 0.0;
+ for (int i = 0; i < MAX_COLORS; ++i) {
+ if (i >= uColorCount) break;
+ s -= 0.01;
+ vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
+ float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0);
+ float kBelow = clamp(uWarpStrength, 0.0, 1.0);
+ float kMix = pow(kBelow, 0.3);
+ float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
+ vec2 disp = (r - s) * kBelow;
+ vec2 warped = s + disp * gain;
+ float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0);
+ float m = mix(m0, m1, kMix);
+ float w = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
+ sumCol += uColors[i] * w;
+ cover = max(cover, w);
+ }
+ col = clamp(sumCol, 0.0, 1.0);
+ a = uTransparent > 0 ? cover : 1.0;
+ } else {
+ vec2 s = q;
+ for (int k = 0; k < 3; ++k) {
+ s -= 0.01;
+ vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
+ float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(k)) / 4.0);
+ float kBelow = clamp(uWarpStrength, 0.0, 1.0);
+ float kMix = pow(kBelow, 0.3);
+ float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
+ vec2 disp = (r - s) * kBelow;
+ vec2 warped = s + disp * gain;
+ float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(k)) / 4.0);
+ float m = mix(m0, m1, kMix);
+ col[k] = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
+ }
+ a = uTransparent > 0 ? max(max(col.r, col.g), col.b) : 1.0;
+ }
+
+ col *= uIntensity;
+
+ if (uNoise > 0.0001) {
+ float n = fract(sin(dot(gl_FragCoord.xy + vec2(uTime), vec2(12.9898, 78.233))) * 43758.5453123);
+ col += (n - 0.5) * uNoise;
+ col = clamp(col, 0.0, 1.0);
+ }
+
+ vec3 rgb = (uTransparent > 0) ? col * a : col;
+ gl_FragColor = vec4(rgb, a);
+}
+`;
+
+const vert = `
+varying vec2 vUv;
+void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+}
+`;
+
+interface ColorBendsProps {
+ className?: string;
+ style?: React.CSSProperties;
+ rotation?: number;
+ speed?: number;
+ colors?: string[];
+ transparent?: boolean;
+ autoRotate?: number;
+ scale?: number;
+ frequency?: number;
+ warpStrength?: number;
+ mouseInfluence?: number;
+ parallax?: number;
+ noise?: number;
+ iterations?: number;
+ intensity?: number;
+ bandWidth?: number;
+}
+
+export default function ColorBends({
+ className = '',
+ style,
+ rotation = 90,
+ speed = 0.2,
+ colors = [],
+ transparent = true,
+ autoRotate = 0,
+ scale = 1,
+ frequency = 1,
+ warpStrength = 1,
+ mouseInfluence = 1,
+ parallax = 0.5,
+ noise = 0.15,
+ iterations = 1,
+ intensity = 1.5,
+ bandWidth = 6,
+}: ColorBendsProps) {
+ const containerRef = useRef
(null);
+ const rendererRef = useRef(null);
+ const rafRef = useRef(null);
+ const materialRef = useRef(null);
+ const resizeObserverRef = useRef(null);
+ const rotationRef = useRef(rotation);
+ const autoRotateRef = useRef(autoRotate);
+ const pointerTargetRef = useRef(new THREE.Vector2(0, 0));
+ const pointerCurrentRef = useRef(new THREE.Vector2(0, 0));
+ const pointerSmoothRef = useRef(8);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+ const geometry = new THREE.PlaneGeometry(2, 2);
+ const uColorsArray = Array.from({ length: MAX_COLORS }, () => new THREE.Vector3(0, 0, 0));
+ const material = new THREE.ShaderMaterial({
+ vertexShader: vert,
+ fragmentShader: frag,
+ uniforms: {
+ uCanvas: { value: new THREE.Vector2(1, 1) },
+ uTime: { value: 0 },
+ uSpeed: { value: speed },
+ uRot: { value: new THREE.Vector2(1, 0) },
+ uColorCount: { value: 0 },
+ uColors: { value: uColorsArray },
+ uTransparent: { value: transparent ? 1 : 0 },
+ uScale: { value: scale },
+ uFrequency: { value: frequency },
+ uWarpStrength: { value: warpStrength },
+ uPointer: { value: new THREE.Vector2(0, 0) },
+ uMouseInfluence: { value: mouseInfluence },
+ uParallax: { value: parallax },
+ uNoise: { value: noise },
+ uIterations: { value: iterations },
+ uIntensity: { value: intensity },
+ uBandWidth: { value: bandWidth },
+ },
+ premultipliedAlpha: true,
+ transparent: true,
+ });
+ materialRef.current = material;
+
+ const mesh = new THREE.Mesh(geometry, material);
+ scene.add(mesh);
+
+ const renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ powerPreference: 'high-performance',
+ alpha: true,
+ });
+ rendererRef.current = renderer;
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.setClearColor(0x000000, transparent ? 0 : 1);
+ renderer.domElement.style.width = '100%';
+ renderer.domElement.style.height = '100%';
+ renderer.domElement.style.display = 'block';
+ container.appendChild(renderer.domElement);
+
+ const clock = new THREE.Clock();
+
+ const handleResize = () => {
+ const w = container.clientWidth || 1;
+ const h = container.clientHeight || 1;
+ renderer.setSize(w, h, false);
+ material.uniforms.uCanvas.value.set(w, h);
+ };
+
+ handleResize();
+
+ if ('ResizeObserver' in window) {
+ const ro = new ResizeObserver(handleResize);
+ ro.observe(container);
+ resizeObserverRef.current = ro;
+ } else {
+ (window as any).addEventListener('resize', handleResize);
+ }
+
+ const loop = () => {
+ const dt = clock.getDelta();
+ const elapsed = clock.elapsedTime;
+ material.uniforms.uTime.value = elapsed;
+
+ const deg = (rotationRef.current % 360) + autoRotateRef.current * elapsed;
+ const rad = (deg * Math.PI) / 180;
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ material.uniforms.uRot.value.set(c, s);
+
+ const cur = pointerCurrentRef.current;
+ const tgt = pointerTargetRef.current;
+ const amt = Math.min(1, dt * pointerSmoothRef.current);
+ cur.lerp(tgt, amt);
+ material.uniforms.uPointer.value.copy(cur);
+ renderer.render(scene, camera);
+ rafRef.current = requestAnimationFrame(loop);
+ };
+ rafRef.current = requestAnimationFrame(loop);
+
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ if (resizeObserverRef.current) resizeObserverRef.current.disconnect();
+ else (window as any).removeEventListener('resize', handleResize);
+ geometry.dispose();
+ material.dispose();
+ renderer.dispose();
+ renderer.forceContextLoss();
+ if (renderer.domElement && renderer.domElement.parentElement === container) {
+ container.removeChild(renderer.domElement);
+ }
+ };
+ }, [bandWidth, frequency, intensity, iterations, mouseInfluence, noise, parallax, scale, speed, transparent, warpStrength]);
+
+ useEffect(() => {
+ const material = materialRef.current;
+ const renderer = rendererRef.current;
+ if (!material) return;
+
+ rotationRef.current = rotation;
+ autoRotateRef.current = autoRotate;
+ material.uniforms.uSpeed.value = speed;
+ material.uniforms.uScale.value = scale;
+ material.uniforms.uFrequency.value = frequency;
+ material.uniforms.uWarpStrength.value = warpStrength;
+ material.uniforms.uMouseInfluence.value = mouseInfluence;
+ material.uniforms.uParallax.value = parallax;
+ material.uniforms.uNoise.value = noise;
+ material.uniforms.uIterations.value = iterations;
+ material.uniforms.uIntensity.value = intensity;
+ material.uniforms.uBandWidth.value = bandWidth;
+
+ const toVec3 = (hex: string) => {
+ const h = hex.replace('#', '').trim();
+ const v =
+ h.length === 3
+ ? [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)]
+ : [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
+ return new THREE.Vector3(v[0] / 255, v[1] / 255, v[2] / 255);
+ };
+
+ const arr = (colors || []).filter(Boolean).slice(0, MAX_COLORS).map(toVec3);
+ for (let i = 0; i < MAX_COLORS; i++) {
+ const vec = material.uniforms.uColors.value[i];
+ if (i < arr.length) vec.copy(arr[i]);
+ else vec.set(0, 0, 0);
+ }
+ material.uniforms.uColorCount.value = arr.length;
+
+ material.uniforms.uTransparent.value = transparent ? 1 : 0;
+ if (renderer) renderer.setClearColor(0x000000, transparent ? 0 : 1);
+ }, [rotation, autoRotate, speed, scale, frequency, warpStrength, mouseInfluence, parallax, noise, iterations, intensity, bandWidth, colors, transparent]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const handlePointerMove = (e: PointerEvent) => {
+ const rect = container.getBoundingClientRect();
+ const x = ((e.clientX - rect.left) / (rect.width || 1)) * 2 - 1;
+ const y = -(((e.clientY - rect.top) / (rect.height || 1)) * 2 - 1);
+ pointerTargetRef.current.set(x, y);
+ };
+
+ container.addEventListener('pointermove', handlePointerMove);
+ return () => {
+ container.removeEventListener('pointermove', handlePointerMove);
+ };
+ }, []);
+
+ return ;
+}
diff --git a/pages/src/components/FadeInSection.tsx b/pages/src/components/FadeInSection.tsx
new file mode 100644
index 0000000..ceb5994
--- /dev/null
+++ b/pages/src/components/FadeInSection.tsx
@@ -0,0 +1,43 @@
+import React, { useRef, useEffect, useState } from 'react';
+
+interface FadeInSectionProps {
+ children: React.ReactNode;
+ delay?: number; // ms
+}
+
+const FadeInSection: React.FC = ({ children, delay = 0 }) => {
+ const ref = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ observer.unobserve(entry.target);
+ }
+ },
+ { threshold: 0.08 }
+ );
+
+ if (ref.current) {
+ observer.observe(ref.current);
+ }
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default FadeInSection;
diff --git a/pages/src/components/FeaturesSection.tsx b/pages/src/components/FeaturesSection.tsx
index 781dbf0..c17784d 100644
--- a/pages/src/components/FeaturesSection.tsx
+++ b/pages/src/components/FeaturesSection.tsx
@@ -1,86 +1,101 @@
import React from 'react';
import { useTranslation } from '../i18n';
+import { useResponsive } from '../hooks/useResponsive';
+import icon1 from '../assets/icons/icon-feature-architecture.svg';
+import icon2 from '../assets/icons/icon-feature-positioning.svg';
+import icon3 from '../assets/icons/icon-feature-multi-model.svg';
+import icon4 from '../assets/icons/icon-feature-concurrent.svg';
+import icon5 from '../assets/icons/icon-feature-compression.svg';
+import icon6 from '../assets/icons/icon-feature-rules.svg';
const FeaturesSection: React.FC = () => {
const { t } = useTranslation();
+ const { isMobile, isTablet } = useResponsive();
const features = [
- {
- icon: 'fa-robot',
- color: 'text-brand-400',
- bgColor: 'bg-brand-500/10',
- title: t('features.feat1Title'),
- description: t('features.feat1Desc'),
- },
- {
- icon: 'fa-crosshairs',
- color: 'text-cyan-400',
- bgColor: 'bg-cyan-500/10',
- title: t('features.feat2Title'),
- description: t('features.feat2Desc'),
- },
- {
- icon: 'fa-link',
- color: 'text-purple-400',
- bgColor: 'bg-purple-500/10',
- title: t('features.feat3Title'),
- description: t('features.feat3Desc'),
- },
- {
- icon: 'fa-bolt',
- color: 'text-yellow-400',
- bgColor: 'bg-yellow-500/10',
- title: t('features.feat4Title'),
- description: t('features.feat4Desc'),
- },
- {
- icon: 'fa-brain',
- color: 'text-orange-400',
- bgColor: 'bg-orange-500/10',
- title: t('features.feat5Title'),
- description: t('features.feat5Desc'),
- },
- {
- icon: 'fa-scroll',
- color: 'text-pink-400',
- bgColor: 'bg-pink-500/10',
- title: t('features.feat6Title'),
- description: t('features.feat6Desc'),
- },
+ { icon: icon1, title: t('features.feat1Title'), desc: t('features.feat1Desc') },
+ { icon: icon2, title: t('features.feat2Title'), desc: t('features.feat2Desc') },
+ { icon: icon3, title: t('features.feat3Title'), desc: t('features.feat3Desc') },
+ { icon: icon4, title: t('features.feat4Title'), desc: t('features.feat4Desc') },
+ { icon: icon5, title: t('features.feat5Title'), desc: t('features.feat5Desc') },
+ { icon: icon6, title: t('features.feat6Title'), desc: t('features.feat6Desc') },
];
return (
-
- {/* Ambient glow */}
-
-
+
+
+ {/* Header */}
+
+
+ {t('features.sectionBadge')}
+
+
+ {t('features.title')}
+
+
+ {t('features.subtitle')}
+
+
-
-
-
- {/* Section pill badge */}
-
- {t('features.sectionBadge')}
-
-
- {t('features.title')}
-
-
- {t('features.subtitle')}
-
-
-
-
- {features.map((feature) => (
-
-
-
-
-
{feature.title}
-
{feature.description}
+ {/* Grid */}
+
+ {features.map((feat, i) => {
+ const cols = isMobile ? 1 : isTablet ? 2 : 3;
+ const isLastCol = (i % cols) === cols - 1;
+ const isLastRow = i >= features.length - (features.length % cols || cols);
+ return (
+
+
+
- ))}
-
+
+
+ {feat.title}
+
+
+ {feat.desc}
+
+
+
+ );
+ })}
diff --git a/pages/src/components/Footer.tsx b/pages/src/components/Footer.tsx
new file mode 100644
index 0000000..48fb09c
--- /dev/null
+++ b/pages/src/components/Footer.tsx
@@ -0,0 +1,125 @@
+import React, { useState, useRef, useEffect } from 'react';
+import githubIcon from '../assets/icons/icon-github.svg';
+import langIcon from '../assets/icons/icon-language.svg';
+import { useTranslation } from '../i18n/context';
+import { useResponsive } from '../hooks/useResponsive';
+import type { Language } from '../i18n/types';
+
+const LANG_OPTIONS: { value: Language; label: string }[] = [
+ { value: 'en', label: 'English' },
+ { value: 'zh', label: '中文' },
+ { value: 'ja', label: '日本語' },
+];
+
+const Footer: React.FC = () => {
+ const { language, setLanguage, t } = useTranslation();
+ const { isMobile } = useResponsive();
+ const [open, setOpen] = useState(false);
+ const ref = useRef
(null);
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
+
+ const currentLabel = LANG_OPTIONS.find(o => o.value === language)?.label ?? 'English';
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx
index 54f022e..692403a 100644
--- a/pages/src/components/HeroSection.tsx
+++ b/pages/src/components/HeroSection.tsx
@@ -1,177 +1,356 @@
-import React, { useEffect, useState } from 'react';
+import React from 'react';
import { useTranslation } from '../i18n';
+import { useResponsive } from '../hooks/useResponsive';
+import ColorBends from './ColorBends';
+import lineIcon from '../assets/icons/icon-terminal-prompt.svg';
const terminalLines = [
- { delay: 0, text: '$ ocr review --from main --to feature-auth', type: 'command' },
- { delay: 600, text: '[ocr] Reviewing 5 file(s) in /home/user/project', type: 'info' },
- { delay: 1200, text: '[ocr] ▶ file_read "internal/auth/login.go"', type: 'tool' },
- { delay: 1600, text: '[ocr] ✔ file_read (15ms)', type: 'tool-ok' },
- { delay: 2000, text: '[ocr] ▶ code_search "password.*hash"', type: 'tool' },
- { delay: 2400, text: '[ocr] ✔ code_search (8ms)', type: 'tool-ok' },
- { delay: 2800, text: '[ocr] Plan completed for internal/auth/login.go', type: 'info' },
- { delay: 3200, text: '', type: 'separator' },
- { delay: 3400, text: '─── internal/auth/login.go:42-55 ───', type: 'header' },
- { delay: 3600, text: 'Consider using bcrypt cost factor ≥ 12 for password hashing.', type: 'comment' },
- { delay: 4000, text: '', type: 'separator' },
- { delay: 4200, text: '[ocr] Summary: 5 file(s), 3 comment(s), ~8421 tokens, 12.5s', type: 'result' }
+ {
+ num: 1,
+ hasIcon: true,
+ content: (
+
+ $ ocr re
+ v
+ iew --from
+
+ mai
+ n --to feature-auth
+
+ ),
+ },
+ {
+ num: 2,
+ hasIcon: true,
+ content: (
+
+ [o
+ cr] R
+ e
+ v
+ iew
+ ing
+
+ 5 file
+ (s) in /home/user/project
+
+ ),
+ },
+ {
+ num: 3,
+ hasIcon: true,
+ content: (
+
+ [o
+ cr]
+ ▶
+ fi
+ l
+ e
+ _re
+ a
+ d
+ "
+ int
+ e
+ rna
+ l
+ /a
+ u
+ th/login.go"
+
+ ),
+ },
+ {
+ num: 4,
+ hasIcon: true,
+ content: (
+
+ [ocr
+ ] ✔
+ f
+ ile
+ _
+ r
+ ead
+ (1
+ 5
+ ms)
+
+ ),
+ },
+ {
+ num: 5,
+ hasIcon: true,
+ content: (
+
+ [ocr]
+ ▶
+ co
+ de_
+ s
+ e
+ arch
+ "p
+ a
+ s
+ swo
+ r
+ d.*hash"
+
+ ),
+ },
+ {
+ num: 6,
+ hasIcon: true,
+ content: (
+
+ [ocr] ✔ c
+ ode
+ _
+ s
+ ear
+ ch
+ (
+ 8
+ ms)
+
+ ),
+ },
+ { num: 7, hasIcon: false, content: [ocr] Plan completed for internal/auth/login.go },
+ { num: 8, hasIcon: false, content: ─── internal/auth/login.go:42-55 ─── },
+ { num: 9, hasIcon: false, content: Consider using bcrypt cost factor ≥ 12 for password hashing. },
+ {
+ num: 10,
+ hasIcon: false,
+ content: (
+
+ [o
+ cr] Su
+ m
+ mar
+ y: 5 file(s), 3 comment(s), ~8421 tokens, 12.5s
+
+ ),
+ },
+ { num: 11, hasIcon: false, content: | },
];
-const TerminalLine: React.FC<{ line: typeof terminalLines[0]; visible: boolean }> = ({ line, visible }) => {
- const colorMap: Record = {
- command: 'text-brand-400 font-semibold',
- info: 'text-slate-400',
- tool: 'text-cyan-400',
- 'tool-ok': 'text-green-400',
- separator: 'text-slate-600',
- header: 'text-slate-500 font-mono',
- comment: 'text-slate-300 text-xs',
- result: 'text-brand-400 font-medium'
- };
-
- return (
-
- {line.text}
-
- );
-};
-
const HeroSection: React.FC = () => {
- const [visibleLines, setVisibleLines] = useState([]);
const { t } = useTranslation();
-
- useEffect(() => {
- const timers: ReturnType[] = [];
- terminalLines.forEach((line, index) => {
- const timer = setTimeout(() => {
- setVisibleLines((prev) => [...prev, index]);
- }, line.delay + 500);
- timers.push(timer);
- });
- return () => { timers.forEach((timer) => clearTimeout(timer)); };
- }, []);
-
- const pills = [
- { icon: 'fa-check-circle', color: 'text-brand-400', label: t('hero.pill1') },
- { icon: 'fa-shield-halved', color: 'text-cyan-400', label: t('hero.pill2') },
- { icon: 'fa-bolt', color: 'text-yellow-400', label: t('hero.pill3') }
- ];
+ const { isMobile, isTablet } = useResponsive();
return (
-
- {/* Layered glow orbs */}
-
-
-
+
+ {/* Shader Background */}
+
- {/* Decorative horizontal lines */}
-
+ {/* Gradient overlay */}
+
-
- {/* Left content */}
-
-
-
- {t('hero.title')}
-
- {t('hero.titleHighlight')}
-
-
- {t('hero.description')}
-
+ {/* Content */}
+
+ {/* Title */}
+
+
+ {t('hero.title').split('\n').map((line, i, arr) => (
+
+ {line}
+ {i < arr.length - 1 &&
}
+
+ ))}
+
+
+ {t('hero.description')}
+
+
+
+ {/* Buttons */}
+
+
+ {/* Terminal */}
+
+ {/* Terminal header */}
+
+
+ {t('hero.terminal')}
+
-
- {/* Feature pills */}
-
- {pills.map((item) => (
-
-
-
{item.label}
+ {/* Terminal body */}
+
+ {terminalLines.map((line) => (
+
+
+
+ {line.num}
+
+ {line.hasIcon &&

}
+
+
+ {line.content}
+
))}
-
- {/* CTA buttons */}
-
-
-
-
-
- {/* Social proof */}
-
-
-
- {t('hero.users')}
-
-
-
-
- {t('hero.openSource')}
-
-
-
- {/* Right terminal */}
-
- {/* Outer glow frame */}
-
-
-
- {/* Scan line effect */}
-
-
-
-
-
-
-
{t('hero.terminal')}
-
-
- {terminalLines.map((line, index) => (
-
- ))}
-
- {visibleLines.length < terminalLines.length && (
-
- )}
-
-
- {/* F1 badge */}
-
-
-
F1: {t('highlights.stat4Value')}
-
{t('hero.badgeLabel')}
-
-
-
- {/* Decorative accent line */}
-
-
-
-
- {/* Scroll indicator */}
-
- Scroll
-
);
diff --git a/pages/src/components/HighlightsSection.tsx b/pages/src/components/HighlightsSection.tsx
index 1e48f07..42c98ba 100644
--- a/pages/src/components/HighlightsSection.tsx
+++ b/pages/src/components/HighlightsSection.tsx
@@ -1,85 +1,162 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useRef, useEffect, useState } from 'react';
import { useTranslation } from '../i18n';
+import { useResponsive } from '../hooks/useResponsive';
+
+// 从字符串中解析数字和前后缀
+function parseStatValue(value: string): { prefix: string; number: number; suffix: string } {
+ // 匹配 "> 30%" 等格式
+ const match = value.match(/^([^\d]*?)(\d+)(.*)$/);
+ if (match) {
+ return { prefix: match[1], number: parseInt(match[2], 10), suffix: match[3] };
+ }
+ return { prefix: '', number: 0, suffix: value };
+}
+
+function useCountUp(target: number, duration: number = 1200, isActive: boolean) {
+ const [current, setCurrent] = useState(0);
+ const rafRef = useRef
(0);
+ const startTimeRef = useRef(0);
+
+ useEffect(() => {
+ if (!isActive) return;
+ startTimeRef.current = performance.now();
+
+ const animate = (now: number) => {
+ const elapsed = now - startTimeRef.current;
+ const progress = Math.min(elapsed / duration, 1);
+ // easeOutExpo
+ const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
+ setCurrent(Math.round(eased * target));
+ if (progress < 1) {
+ rafRef.current = requestAnimationFrame(animate);
+ }
+ };
+
+ rafRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(rafRef.current);
+ }, [isActive, target, duration]);
+
+ return current;
+}
+
+const CountUpValue: React.FC<{ value: string; isVisible: boolean }> = ({ value, isVisible }) => {
+ const { prefix, number, suffix } = parseStatValue(value);
+ const count = useCountUp(number, 2000, isVisible);
+
+ if (number === 0) {
+ // 无法解析数字,直接显示原文
+ return <>{value}>;
+ }
+
+ return <>{prefix}{isVisible ? count : 0}{suffix}>;
+};
const HighlightsSection: React.FC = () => {
const { t } = useTranslation();
+ const { isMobile, isTablet } = useResponsive();
const sectionRef = useRef(null);
- const [visible, setVisible] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
- setVisible(true);
- observer.disconnect();
+ setIsVisible(true);
+ observer.unobserve(entry.target);
}
},
{ threshold: 0.3 }
);
- if (sectionRef.current) observer.observe(sectionRef.current);
+ if (sectionRef.current) {
+ observer.observe(sectionRef.current);
+ }
return () => observer.disconnect();
}, []);
const stats = [
- {
- label: t('highlights.stat1Label'),
- value: t('highlights.stat1Value'),
- caption: t('highlights.stat1Caption'),
- delay: 0,
- },
- {
- label: t('highlights.stat2Label'),
- value: t('highlights.stat2Value'),
- caption: t('highlights.stat2Caption'),
- delay: 100,
- },
- {
- label: t('highlights.stat3Label'),
- value: t('highlights.stat3Value'),
- caption: t('highlights.stat3Caption'),
- delay: 200,
- },
- {
- label: t('highlights.stat4Label'),
- value: t('highlights.stat4Value'),
- caption: t('highlights.stat4Caption'),
- delay: 300,
- },
+ { value: t('highlights.stat1Value'), label: t('highlights.stat1Label'), caption: t('highlights.stat1Caption') },
+ { value: t('highlights.stat2Value'), label: t('highlights.stat2Label'), caption: t('highlights.stat2Caption') },
+ { value: t('highlights.stat3Value'), label: t('highlights.stat3Label'), caption: t('highlights.stat3Caption') },
+ { value: t('highlights.stat4Value'), label: t('highlights.stat4Label'), caption: t('highlights.stat4Caption') },
+ { value: t('highlights.stat5Value'), label: t('highlights.stat5Label'), caption: t('highlights.stat5Caption') },
];
return (
-
-
-
-
-
-
-
- {stats.map((stat, i) => (
-
-
-
- {stat.value}
-
- {stat.caption}
-
- ))}
+
+
+ {stats.map((stat, i) => (
+
+
+
+
+
+
+ {stat.label}
+
+
+ {stat.caption}
+
-
+ ))}
);
diff --git a/pages/src/components/LandingPage.tsx b/pages/src/components/LandingPage.tsx
index 573ae7a..a011807 100644
--- a/pages/src/components/LandingPage.tsx
+++ b/pages/src/components/LandingPage.tsx
@@ -1,36 +1,31 @@
-import React, { useEffect } from 'react';
-import { useLocation } from 'react-router-dom';
+import React from 'react';
import Navbar from './Navbar';
-import HeroSection from './HeroSection';
-import HighlightsSection from './HighlightsSection';
-import WhySection from './WhySection';
-import FeaturesSection from './FeaturesSection';
-import BenchmarkSection from './BenchmarkSection';
-import QuickStartSection from './QuickStartSection';
-const LandingPage: React.FC = () => {
- const location = useLocation();
-
- useEffect(() => {
- const scrollTo = (location.state as { scrollTo?: string })?.scrollTo;
- if (scrollTo) {
- const el = document.getElementById(scrollTo);
- if (el) {
- setTimeout(() => el.scrollIntoView({ behavior: 'smooth' }), 100);
- }
- window.history.replaceState({}, '');
- }
- }, [location.state]);
+interface LandingPageProps {
+ children: React.ReactNode;
+}
+const LandingPage: React.FC
= ({ children }) => {
return (
-
-
-
-
-
-
-
-
+
);
};
diff --git a/pages/src/components/Navbar.tsx b/pages/src/components/Navbar.tsx
index 57cfc86..4488d75 100644
--- a/pages/src/components/Navbar.tsx
+++ b/pages/src/components/Navbar.tsx
@@ -1,146 +1,216 @@
-import React, { useState, useEffect } from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import React, { useState, useRef, useEffect } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from '../i18n';
-import logoSvg from '../../logo.svg';
+import { useResponsive } from '../hooks/useResponsive';
+import socialIcon from '../assets/icons/icon-github.svg';
+import brandIcon from '../assets/images/brandicon.svg';
+
+import type { Language } from '../i18n/types';
+
+const LANG_OPTIONS: { value: Language; label: string }[] = [
+ { value: 'en', label: 'English' },
+ { value: 'zh', label: '中文' },
+ { value: 'ja', label: '日本語' },
+];
+
+const navTabs = [
+ { path: '/', labelKey: 'navbar.features' },
+ { path: '/benchmark', labelKey: 'navbar.benchmark' },
+ { path: '/quickstart', labelKey: 'navbar.quickstart' },
+ { path: '/docs', labelKey: 'navbar.docs' },
+];
const Navbar: React.FC = () => {
- const [isScrolled, setIsScrolled] = useState(false);
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const { language, setLanguage, t } = useTranslation();
+ const { isMobile } = useResponsive();
const location = useLocation();
const navigate = useNavigate();
- const isDocsPage = location.pathname === '/docs';
- const { t, language, setLanguage } = useTranslation();
-
- const navItems = [
- { label: t('navbar.features'), id: 'features' },
- { label: t('navbar.benchmark'), id: 'benchmark' },
- { label: t('navbar.quickstart'), id: 'quickstart' }
- ];
-
- const navigateToSection = (sectionId: string) => {
- navigate('/', { state: { scrollTo: sectionId } });
- setIsMobileMenuOpen(false);
- };
+ const [langOpen, setLangOpen] = useState(false);
+ const langRef = useRef
(null);
useEffect(() => {
- const handleScroll = () => setIsScrolled(window.scrollY > 20);
- window.addEventListener('scroll', handleScroll);
- return () => window.removeEventListener('scroll', handleScroll);
+ const handler = (e: MouseEvent) => {
+ if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
}, []);
- const scrollToSection = (sectionId: string) => {
- const element = document.getElementById(sectionId);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth' });
- }
- setIsMobileMenuOpen(false);
- };
-
- const toggleLanguage = () => {
- setLanguage(language === 'en' ? 'zh' : 'en');
- };
+ const currentPath = location.pathname;
return (