From 50ee98669e1316e96efc8151d32907ad06dc7def Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 14 Sep 2026 18:51:59 +0530 Subject: [PATCH] refactor!: remove dead packages with no importers Delete 30 packages that nothing imports, are not referenced by CI or scripts, and are not documented as active seams: deps, engine/async, engine/code, engine/prompt, engine/trajectory, fastcopy, feature/ide, feature/voice, hunktracker, localize, magicdocs, observability/{alerts,conformance,logfield}, preset, profile, system/{bus,retention,shutdown}, task, tools, container, bench, tui/diff, filestate, gitsnapshot, lockutil, intelligence/harness, system/cron, and the unused root context/resilience/system files. Kept: context/repomap (used by cmd/options.go), resilience/{health,retry, ratelimit} (used by engine + verify-milestone.sh), and testaudit (CI package-boundary guard). Drop the README adopted-capabilities rows and doc references for the removed packages. --- README.md | 4 - docs/COMPETITIVE.md | 2 +- docs/design/ECOSYSTEM-MARKETPLACE.md | 4 +- docs/design/HAWK-CLOUD-SAAS.md | 4 +- internal/bench/bench_test.go | 114 --- internal/bench/suite.go | 122 --- internal/container/Dockerfile | 116 --- internal/container/lifecycle.go | 203 ----- internal/container/lifecycle_test.go | 350 -------- internal/context/frontmatter.go | 194 ----- internal/context/frontmatter_test.go | 173 ---- internal/context/rules.go | 293 ------- internal/context/rules_test.go | 303 ------- internal/context/walkup.go | 263 ------ internal/context/walkup_test.go | 213 ----- internal/deps/deps.go | 31 - internal/engine/async/engine.go | 283 ------ internal/engine/async/engine_test.go | 357 -------- internal/engine/async/event.go | 183 ---- internal/engine/code/aliases.go | 3 - internal/engine/code/code_actions.go | 605 ------------- internal/engine/code/code_actions_test.go | 550 ------------ internal/engine/code/code_context.go | 545 ------------ internal/engine/code/code_context_test.go | 536 ------------ internal/engine/code/code_explainer.go | 610 ------------- .../engine/code/code_explainer_helpers.go | 440 ---------- internal/engine/code/code_explainer_test.go | 811 ------------------ internal/engine/code/code_lens.go | 539 ------------ internal/engine/code/code_lens_test.go | 626 -------------- internal/engine/code/coverage_extra_test.go | 128 --- internal/engine/prompt/aliases.go | 37 - internal/engine/prompt/aliases_extra_test.go | 41 - internal/engine/prompt/prompt_extra_test.go | 103 --- internal/engine/prompt/prompt_optimizer.go | 251 ------ .../engine/prompt/prompt_optimizer_test.go | 135 --- internal/engine/prompt/prompt_tuner.go | 148 ---- internal/engine/prompt/prompt_tuner_test.go | 42 - internal/engine/trajectory/trajectory.go | 135 --- internal/engine/trajectory/trajectory_test.go | 86 -- internal/fastcopy/clone_darwin.go | 21 - internal/fastcopy/clone_other.go | 8 - internal/fastcopy/fastcopy.go | 182 ---- internal/fastcopy/fastcopy_test.go | 87 -- internal/feature/ide/ide.go | 151 ---- internal/feature/ide/ide_test.go | 45 - internal/feature/voice/voice.go | 85 -- internal/feature/voice/voice_extra_test.go | 140 --- internal/feature/voice/voice_test.go | 28 - internal/filestate/filestate.go | 313 ------- internal/filestate/filestate_test.go | 169 ---- internal/gitsnapshot/snapshot.go | 371 -------- internal/gitsnapshot/snapshot_test.go | 223 ----- internal/hunktracker/hunktracker.go | 235 ----- internal/hunktracker/hunktracker_test.go | 91 -- internal/intelligence/harness/harness.go | 322 ------- internal/intelligence/harness/harness_test.go | 115 --- internal/localize/files.go | 213 ----- internal/localize/localize.go | 201 ----- internal/localize/localize_test.go | 429 --------- internal/localize/options.go | 57 -- internal/localize/symbols.go | 330 ------- internal/localize/symbols_test.go | 513 ----------- internal/lockutil/lockutil.go | 58 -- internal/lockutil/lockutil_other.go | 45 - internal/lockutil/lockutil_windows.go | 75 -- internal/lockutil/reclaim.go | 59 -- internal/lockutil/reclaim_test.go | 101 --- internal/magicdocs/auto_update.go | 117 --- internal/magicdocs/auto_update_test.go | 78 -- internal/magicdocs/magicdocs.go | 103 --- internal/magicdocs/magicdocs_test.go | 70 -- internal/observability/alerts/cooldown.go | 138 --- .../observability/alerts/cooldown_test.go | 149 ---- .../conformance/conformance_test.go | 96 --- internal/observability/conformance/harness.go | 60 -- internal/observability/conformance/schema.go | 148 ---- internal/observability/logfield/fields.go | 147 ---- .../observability/logfield/fields_test.go | 165 ---- internal/preset/preset.go | 258 ------ internal/preset/preset_test.go | 127 --- internal/profile/profile.go | 93 -- internal/profile/profile_test.go | 62 -- internal/resilience/circuit.go | 250 ------ internal/resilience/circuit_test.go | 212 ----- internal/system/bus/bus.go | 65 -- internal/system/bus/bus_test.go | 170 ---- internal/system/cron/cron.go | 330 ------- internal/system/cron/cron_test.go | 283 ------ internal/system/retention/retention.go | 124 --- internal/system/retention/retention_test.go | 130 --- internal/system/shutdown/shutdown.go | 118 --- internal/system/shutdown/shutdown_test.go | 121 --- internal/system/trie.go | 126 --- internal/task/task.go | 200 ----- internal/task/task_test.go | 304 ------- internal/tools/lsp_tools.go | 433 ---------- internal/tools/lsp_tools_execute_test.go | 566 ------------ internal/tools/lsp_tools_metadata_test.go | 198 ----- internal/tools/lsp_tools_test.go | 66 -- internal/tui/diff/diff.go | 85 -- internal/tui/diff/diff_test.go | 78 -- 101 files changed, 5 insertions(+), 19915 deletions(-) delete mode 100644 internal/bench/bench_test.go delete mode 100644 internal/bench/suite.go delete mode 100644 internal/container/Dockerfile delete mode 100644 internal/container/lifecycle.go delete mode 100644 internal/container/lifecycle_test.go delete mode 100644 internal/context/frontmatter.go delete mode 100644 internal/context/frontmatter_test.go delete mode 100644 internal/context/rules.go delete mode 100644 internal/context/rules_test.go delete mode 100644 internal/context/walkup.go delete mode 100644 internal/context/walkup_test.go delete mode 100644 internal/deps/deps.go delete mode 100644 internal/engine/async/engine.go delete mode 100644 internal/engine/async/engine_test.go delete mode 100644 internal/engine/async/event.go delete mode 100644 internal/engine/code/aliases.go delete mode 100644 internal/engine/code/code_actions.go delete mode 100644 internal/engine/code/code_actions_test.go delete mode 100644 internal/engine/code/code_context.go delete mode 100644 internal/engine/code/code_context_test.go delete mode 100644 internal/engine/code/code_explainer.go delete mode 100644 internal/engine/code/code_explainer_helpers.go delete mode 100644 internal/engine/code/code_explainer_test.go delete mode 100644 internal/engine/code/code_lens.go delete mode 100644 internal/engine/code/code_lens_test.go delete mode 100644 internal/engine/code/coverage_extra_test.go delete mode 100644 internal/engine/prompt/aliases.go delete mode 100644 internal/engine/prompt/aliases_extra_test.go delete mode 100644 internal/engine/prompt/prompt_extra_test.go delete mode 100644 internal/engine/prompt/prompt_optimizer.go delete mode 100644 internal/engine/prompt/prompt_optimizer_test.go delete mode 100644 internal/engine/prompt/prompt_tuner.go delete mode 100644 internal/engine/prompt/prompt_tuner_test.go delete mode 100644 internal/engine/trajectory/trajectory.go delete mode 100644 internal/engine/trajectory/trajectory_test.go delete mode 100644 internal/fastcopy/clone_darwin.go delete mode 100644 internal/fastcopy/clone_other.go delete mode 100644 internal/fastcopy/fastcopy.go delete mode 100644 internal/fastcopy/fastcopy_test.go delete mode 100644 internal/feature/ide/ide.go delete mode 100644 internal/feature/ide/ide_test.go delete mode 100644 internal/feature/voice/voice.go delete mode 100644 internal/feature/voice/voice_extra_test.go delete mode 100644 internal/feature/voice/voice_test.go delete mode 100644 internal/filestate/filestate.go delete mode 100644 internal/filestate/filestate_test.go delete mode 100644 internal/gitsnapshot/snapshot.go delete mode 100644 internal/gitsnapshot/snapshot_test.go delete mode 100644 internal/hunktracker/hunktracker.go delete mode 100644 internal/hunktracker/hunktracker_test.go delete mode 100644 internal/intelligence/harness/harness.go delete mode 100644 internal/intelligence/harness/harness_test.go delete mode 100644 internal/localize/files.go delete mode 100644 internal/localize/localize.go delete mode 100644 internal/localize/localize_test.go delete mode 100644 internal/localize/options.go delete mode 100644 internal/localize/symbols.go delete mode 100644 internal/localize/symbols_test.go delete mode 100644 internal/lockutil/lockutil.go delete mode 100644 internal/lockutil/lockutil_other.go delete mode 100644 internal/lockutil/lockutil_windows.go delete mode 100644 internal/lockutil/reclaim.go delete mode 100644 internal/lockutil/reclaim_test.go delete mode 100644 internal/magicdocs/auto_update.go delete mode 100644 internal/magicdocs/auto_update_test.go delete mode 100644 internal/magicdocs/magicdocs.go delete mode 100644 internal/magicdocs/magicdocs_test.go delete mode 100644 internal/observability/alerts/cooldown.go delete mode 100644 internal/observability/alerts/cooldown_test.go delete mode 100644 internal/observability/conformance/conformance_test.go delete mode 100644 internal/observability/conformance/harness.go delete mode 100644 internal/observability/conformance/schema.go delete mode 100644 internal/observability/logfield/fields.go delete mode 100644 internal/observability/logfield/fields_test.go delete mode 100644 internal/preset/preset.go delete mode 100644 internal/preset/preset_test.go delete mode 100644 internal/profile/profile.go delete mode 100644 internal/profile/profile_test.go delete mode 100644 internal/resilience/circuit.go delete mode 100644 internal/resilience/circuit_test.go delete mode 100644 internal/system/bus/bus.go delete mode 100644 internal/system/bus/bus_test.go delete mode 100644 internal/system/cron/cron.go delete mode 100644 internal/system/cron/cron_test.go delete mode 100644 internal/system/retention/retention.go delete mode 100644 internal/system/retention/retention_test.go delete mode 100644 internal/system/shutdown/shutdown.go delete mode 100644 internal/system/shutdown/shutdown_test.go delete mode 100644 internal/system/trie.go delete mode 100644 internal/task/task.go delete mode 100644 internal/task/task_test.go delete mode 100644 internal/tools/lsp_tools.go delete mode 100644 internal/tools/lsp_tools_execute_test.go delete mode 100644 internal/tools/lsp_tools_metadata_test.go delete mode 100644 internal/tools/lsp_tools_test.go delete mode 100644 internal/tui/diff/diff.go delete mode 100644 internal/tui/diff/diff_test.go diff --git a/README.md b/README.md index f1d7df145..21ce133d6 100644 --- a/README.md +++ b/README.md @@ -269,9 +269,6 @@ Features adopted from open-source agent projects. All are off by default unless | App verification | `AppVerify` tool | Boot-smoke check with readiness polling and evidence artifacts | | Media generation | `GenerateMedia` tool | Image/video generation with local persistence. Backend via `tool.SetMediaEngine`; an OpenAI-compatible client ships in `eyrie/client` (`ImageClient`), wired by the host (boundary-guarded — hawk routes through the eyrie facade) | | Voice transcription | Telegram voice notes + `stt` package | Transcribe Telegram voice/audio into the prompt. Backend via `stt.SetTranscriber`; an OpenAI-compatible client ships in `eyrie/client` (`AudioClient`), wired by the host | -| Git-tree file snapshots | `internal/gitsnapshot` | Content-addressed tree capture/diff/preview/restore | -| Turn-boundary rewind | `internal/filestate` | Per-prompt before/after snapshots with durable store | -| Continual harness | `internal/intelligence/harness` | Versioned, evidence-backed refinement of supplemental prompts/memories/skills/subagents with rollback | | Bounded autonomous budgets | `internal/engine` (`AutonomousBudget`) | Track turns/tokens/time/continuations; report why a run stopped (budget vs gate-passed vs error) | | Agent family messaging | `internal/multiagent` (`FamilyMessenger`) | Direct parent/sibling/child messages with pending caps + rate limits | | Path reservations | `internal/multiagent` ledger | Detect overlapping-file changes between parallel branches | @@ -281,7 +278,6 @@ Features adopted from open-source agent projects. All are off by default unless | Token-cheaper file views | `Read` tool `--minify` | Read-only, comment-stripped, whitespace-dense file view (Go via `go/parser`; other languages string-aware; never touches disk) — fewer tokens per read | | Classified provider hints | `internal/errhint` | Buckets provider errors (Auth/RateLimit/Connectivity/ModelNotFound/ContextOverflow) into a one-line fixable next step. Wired into TUI error rows (`friendlyErrorMessage`) and `hawk exec` CLI errors | | Atomic install transactions | `internal/installtxn` | Cross-process staged install/remove with rollback. Wired into skill install (atomic `SKILL.md` publish) | -| Stale-lock reclaim | `internal/lockutil` | Race-correct atomic reclaim of O_EXCL lock files with live-restore (ready for O_EXCL lock sites) | | Test command discovery | `internal/testrunner` | Auto-detect test/verify commands (Go/npm/bun/pnpm/yarn/pytest/cargo) and parse runner output into structured results. Wired into `hawk verify` | | Circuit breaker | `internal/circuitbreaker` | Closed/open/half-open retry-storm protection with cooldown. Wired into auto-compaction (cooldown + half-open auto-retry) | | Smart turn routing | `internal/smartrouting` | Deterministic simple/strong turn classifier with fail-toward-strong safety. Wired into per-turn model selection (`settings.smart_routing`) | diff --git a/docs/COMPETITIVE.md b/docs/COMPETITIVE.md index 711cdd8f6..6e739c7ce 100644 --- a/docs/COMPETITIVE.md +++ b/docs/COMPETITIVE.md @@ -16,7 +16,7 @@ Verified from source in this repo: - Custom providers: supported — `internal/config/settings.go:50` (`custom_providers`), `internal/config/eyrie_engine.go:32-50`. - Unwired backends: `internal/tool/computer_use.go:67-94` (`SetComputerBackend`, nil default), `internal/tool/media_generation.go:69-71` (`SetMediaEngine`, nil default). - Terminal detect covers kitty/ghostty/wezterm/alacritty names (`internal/ui/icons/detect_test.go:56`); Kitty graphics protocol not implemented (see `docs/plans/pi-adoption-plan.md:25`). -- Bench infra exists (`internal/bench/suite.go`, `internal/feature/eval/`, `make bench`) but README publishes no numbers. +- Bench infra exists (`internal/feature/eval/`, `make bench`) but README publishes no numbers. External star counts below are approximate web-search snapshots (2026-09-08), not repo-verified. Treat as order-of-magnitude traction, not exact rankings. hawk is pre-release (`VERSION`: `0.0.1`, `README.md:40-44` source-build primary) — it competes on architecture, not stars. diff --git a/docs/design/ECOSYSTEM-MARKETPLACE.md b/docs/design/ECOSYSTEM-MARKETPLACE.md index bf4cdfbed..82721c324 100644 --- a/docs/design/ECOSYSTEM-MARKETPLACE.md +++ b/docs/design/ECOSYSTEM-MARKETPLACE.md @@ -231,7 +231,7 @@ Concrete reuse map — what is *already there* and what each piece becomes: | Hook bundling | `ManifestHook{Event,Command,Priority}` | `manifest_v2.go:32` | | MCP server bundling | MCP client/loader | `hawk/internal/mcp/mcp.go`, `hawk/internal/tool/mcp_tool.go` | | Sub-agent bundling | Persona system (YAML frontmatter MD) | `hawk/internal/multiagent/agents/` (cf. `TOP20_COMPARISON.md:62`) | -| Rule/skill discovery precedence | `DefaultRuleSources`, `RuleDiscoverer` | `hawk/internal/context/rules.go:20`, `:46` | +| Rule/skill discovery precedence | `DefaultRuleSources`, `RuleDiscoverer` | `hawk/internal/plugin/` rule discovery | | Trust — static scan | `malware_check.go` (blocked/suspicious regex) | `hawk/internal/plugin/malware_check.go:19` | | Trust — unicode/homoglyph | `audit.go` (`AuditFinding`, severities) | `hawk/internal/plugin/audit.go:12-22` | | Registry generators/validators | Python tooling | `starling/tools/{update_registry,registry_schema,validate_skill,package_skill,sync_marketplace}.py` | @@ -366,7 +366,7 @@ The estimate is bounded on the low side because the hardest plumbing — the reg - `hawk/internal/plugin/malware_check.go:19` - `hawk/internal/plugin/audit.go:12` - `hawk/internal/tool/skill.go:62,91` -- `hawk/internal/context/rules.go:20,46,97` +- `hawk/internal/plugin/` rule discovery - `hawk/cmd/skills_cmd.go:16-211` - `hawk/internal/mcp/mcp.go`, `hawk/internal/tool/mcp_tool.go` - `TOP20_COMPARISON.md:65,74,83,181,227,232,240,241` diff --git a/docs/design/HAWK-CLOUD-SAAS.md b/docs/design/HAWK-CLOUD-SAAS.md index 3ad9d42b0..9322d328a 100644 --- a/docs/design/HAWK-CLOUD-SAAS.md +++ b/docs/design/HAWK-CLOUD-SAAS.md @@ -315,7 +315,7 @@ This is the crux: **what is reusable today vs. net-new.** | Sandbox executor interface | `internal/sandbox/container.go:18-21` (`containerExecutor`: `Exec`, `Running`) | `CloudSandbox` implements the same interface → drop-in. Callers don't know if they're on local Docker or a cloud microVM. | | Sandbox lifecycle manager | `internal/sandbox/snapshot_sandbox.go:52-228` (`Create/Pause/Resume/Snapshot/Restore/List/Cleanup`) | Existing pause/resume/snapshot semantics map cleanly onto E2B/Daytona pause+snapshot APIs; the manager abstraction guides the `CloudSandbox` API shape. | | Messaging gateways | `internal/daemon/gateway.go`, `telegram.go`, `discord.go`, `slack.go` | Already forward to `/v1/chat` via `forwardToHawk` (`gateway.go:17`) with bearer auth. In cloud they forward to the tenant-scoped chat endpoint with the org's key — minimal change. | -| Cron engine | `internal/system/cron/cron.go` | Foundation for cloud scheduled runs (Routines), per `TOP20_COMPARISON.md:48`. Out of scope here but shares the worker plane. | +| Cron engine | (not yet implemented) | Cloud scheduled runs (Routines) are planned, per `TOP20_COMPARISON.md:48`. Out of scope here but shares the worker plane. | ### Net-new (must build) @@ -387,7 +387,7 @@ credits hit zero. - **M2.3** JetBrains extension (ACP). - **M2.4** Org-scoped provider config (BYO keys / BYO model endpoints), aligning with eyrie multi-tenant proxy (`TOP20_COMPARISON.md:96`). - **M2.5** Audit logging, IT-managed policy tiers, self-hosted/on-prem distribution. -- **M2.6** Cloud Routines (scheduled/triggered runs) on the worker plane via `internal/system/cron/cron.go` (`TOP20_COMPARISON.md:48`). +- **M2.6** Cloud Routines (scheduled/triggered runs) on the worker plane (`TOP20_COMPARISON.md:48`). --- diff --git a/internal/bench/bench_test.go b/internal/bench/bench_test.go deleted file mode 100644 index 4f41a07b3..000000000 --- a/internal/bench/bench_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package bench - -import ( - "context" - "os" - "testing" - "time" - - "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/GrayCodeAI/hawk/internal/types" -) - -// taskFixture is a minimal SWE-bench-style task: an instruction plus the set -// of file paths the solution is expected to touch. The harness runs the agent -// loop against a stub provider and checks those files were referenced via tool -// calls — a smoke test for the end-to-end agent pipeline (headless, no TUI). -type taskFixture struct { - Name string - Prompt string - MustUse []string // tool names the solution must emit -} - -// sWEbenchSmokeTasks is a small curated fixture set exercising the common -// agent shapes: read+edit, planning, and a no-op task. Kept tiny so the bench -// stays a compile+smoke gate (real SWE-bench evaluation uses real providers -// and is gated by HAWK_BENCH_PROVIDER / HAWK_BENCH_API_KEY, which is why this -// test only runs when that env is set — see TestBenchmark_SWE_benchHeadless). -var sWEbenchSmokeTasks = []taskFixture{ - { - Name: "read-fix", - Prompt: "Read internal/engine/stream.go and describe the retry timer.", - MustUse: []string{"Read"}, - }, - { - Name: "no-tools", - Prompt: "What is the capital of France?", - MustUse: nil, - }, -} - -// stubChatClient is a headless ChatClient that re-emits a canned event stream -// per StreamChatContinue call. It records the tool calls it was asked to emit -// so a benchmark can assert the agent attempted them. -type stubChatClient struct { - t testing.TB - events []types.EyrieStreamEvent -} - -func (m *stubChatClient) Chat(_ context.Context, _ []types.EyrieMessage, _ types.ChatOptions) (*types.EyrieResponse, error) { - return &types.EyrieResponse{Content: "stub", FinishReason: "end_turn"}, nil -} - -func (m *stubChatClient) StreamChatContinue(_ context.Context, _ []types.EyrieMessage, _ types.ChatOptions, _ types.ContinuationConfig) (*types.StreamResult, error) { - ch := make(chan types.EyrieStreamEvent, len(m.events)+1) - for _, e := range m.events { - ch <- e - } - close(ch) - return &types.StreamResult{Events: ch}, nil -} - -// runTask drives engine.Session.Stream for a single fixture and returns the -// flattened event sequence + whether the loop terminated cleanly. -func runTask(t *testing.B, fix taskFixture, events []types.EyrieStreamEvent) (terminated bool, got []engine.StreamEvent) { - svc := &stubChatClient{t: t, events: events} - s := engine.NewSessionWithClient(svc, "bench", "bench-model", "bench system", nil, false) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - ch, err := s.Stream(ctx) - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - for ev := range ch { - got = append(got, ev) - if ev.Type == "done" { - terminated = true - } - } - return terminated, got -} - -// TestBenchmark_SWE_benchHeadless is a compile + smoke gate for the headless -// agent loop. It is skipped unless HAWK_BENCH_HEADLESS=1 is set so it never -// runs in CI by default (it uses a stub provider). Real provider-backed SWE -// harness execution lives in bench_suite.go and is invoked via `hawk bench`. -func TestBenchmark_SWE_benchHeadless(t *testing.T) { - if v := os.Getenv("HAWK_BENCH_HEADLESS"); v != "1" { - // TODO: track scheduling the headless benchmark smoke in CI. - t.Skip("set HAWK_BENCH_HEADLESS=1 to run agent-loop benchmark smoke") - } - b := &testing.B{} - for _, fix := range sWEbenchSmokeTasks { - // The stub emits a content + done event, modelling a provider that - // answers without tools (the no-tools path) or a read+answer path. - events := []types.EyrieStreamEvent{ - {Type: "content", Content: "understood"}, - } - if len(fix.MustUse) > 0 { - // Simulate a tool-use turn followed by completion. - events = nil - for _, name := range fix.MustUse { - events = append(events, types.EyrieStreamEvent{Type: "tool_use", Content: name}) - } - events = append(events, types.EyrieStreamEvent{Type: "content", Content: "done"}) - } - events = append(events, types.EyrieStreamEvent{Type: "done", StopReason: "end_turn"}) - - terminated, got := runTask(b, fix, events) - if !terminated { - t.Errorf("%s: stream did not terminate with a done event (got %d events)", fix.Name, len(got)) - } - } -} diff --git a/internal/bench/suite.go b/internal/bench/suite.go deleted file mode 100644 index 042921863..000000000 --- a/internal/bench/suite.go +++ /dev/null @@ -1,122 +0,0 @@ -package bench - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/GrayCodeAI/hawk/internal/ui/icons" -) - -// BenchmarkResult holds the result of a single benchmark run. -type BenchmarkResult struct { - Name string `json:"name"` - Duration time.Duration `json:"duration_ms"` - Score float64 `json:"score"` - Metric string `json:"metric"` - Passed bool `json:"passed"` - Details string `json:"details,omitempty"` -} - -// BenchmarkSuite runs Hawk's benchmark suite. -type BenchmarkSuite struct { - Results []BenchmarkResult `json:"results"` -} - -// RunAll runs all available benchmarks and returns the results. -func RunAll(projectDir string) (*BenchmarkSuite, error) { - suite := &BenchmarkSuite{} - - // Run the embedded token-engine benchmarks. - if tokenResults, err := runTokenBench(projectDir); err == nil { - suite.Results = append(suite.Results, tokenResults...) - } - - // Run hawk build benchmark. - if hawkResult, err := runHawkBuildBench(projectDir); err == nil { - suite.Results = append(suite.Results, hawkResult) - } - - return suite, nil -} - -// runTokenBench runs the embedded token engine's benchmark suite. -func runTokenBench(projectDir string) ([]BenchmarkResult, error) { - if _, err := os.Stat(filepath.Join(projectDir, "go.mod")); err != nil { - return nil, fmt.Errorf("hawk not found") - } - - start := time.Now() - cmd := exec.CommandContext(context.Background(), "go", "test", "-bench=.", "-benchmem", "-count=1", "-timeout=60s", "./internal/token/...") - cmd.Dir = projectDir - output, err := cmd.CombinedOutput() - duration := time.Since(start) - - result := BenchmarkResult{ - Name: "token", - Duration: duration, - Metric: "time", - Passed: err == nil, - Details: string(output), - } - if err == nil { - result.Score = float64(duration.Milliseconds()) - } - - return []BenchmarkResult{result}, nil -} - -// runHawkBuildBench measures hawk build time. -func runHawkBuildBench(projectDir string) (BenchmarkResult, error) { - hawkDir := filepath.Join(projectDir) - if _, err := os.Stat(filepath.Join(hawkDir, "go.mod")); err != nil { - return BenchmarkResult{}, fmt.Errorf("hawk not found") - } - - start := time.Now() - cmd := exec.CommandContext(context.Background(), "go", "build", "-o", "/dev/null", ".") - cmd.Dir = hawkDir - err := cmd.Run() - duration := time.Since(start) - - return BenchmarkResult{ - Name: "hawk/build", - Duration: duration, - Score: float64(duration.Milliseconds()), - Metric: "time", - Passed: err == nil, - }, nil -} - -// FormatReport returns a human-readable benchmark report. -func (s *BenchmarkSuite) FormatReport() string { - var report string - report += "## graycode-eco Benchmark Report\n\n" - report += fmt.Sprintf("Generated: %s\n\n", time.Now().Format(time.RFC3339)) - report += "| Benchmark | Duration | Score | Status |\n" - report += "|-----------|----------|-------|--------|\n" - - for _, r := range s.Results { - status := icons.CheckBold() - if !r.Passed { - status = icons.CloseThick() - } - report += fmt.Sprintf("| %s | %s | %.0fms | %s |\n", - r.Name, r.Duration.Round(time.Millisecond), r.Score, status) - } - - return report -} - -// SaveJSON saves the benchmark results to a JSON file. -func (s *BenchmarkSuite) SaveJSON(path string) error { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o600) -} diff --git a/internal/container/Dockerfile b/internal/container/Dockerfile deleted file mode 100644 index 7575c25c8..000000000 --- a/internal/container/Dockerfile +++ /dev/null @@ -1,116 +0,0 @@ -FROM ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea - -ENV DEBIAN_FRONTEND=noninteractive - -# ───── 1. Base system + existing tools ───── -# gpg and gnupg are needed early for keyring-based repo setup (gh, docker, terraform). -RUN apt-get update && apt-get install -y --no-install-recommends \ - git curl wget jq tree ripgrep fd-find make gcc g++ \ - python3 python3-pip python3-venv \ - nodejs npm \ - ca-certificates openssh-client unzip xz-utils \ - gpg gnupg gettext-base \ - && rm -rf /var/lib/apt/lists/* \ - && ln -sf /usr/bin/fdfind /usr/bin/fd - -# ───── 2. Go ───── -RUN curl -fsSL https://go.dev/dl/go1.26.3.linux-$(dpkg --print-architecture).tar.gz \ - | tar -C /usr/local -xz -ENV PATH="/usr/local/go/bin:${PATH}" -ENV GOPATH="/root/go" -ENV PATH="${GOPATH}/bin:${PATH}" - -# ───── 3. GitHub CLI (gh) ───── -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && apt-get update && apt-get install -y gh \ - && rm -rf /var/lib/apt/lists/* - -# ───── 4. Docker CLI (client only) ───── -RUN install -m 0755 -d /etc/apt/keyrings \ - && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ - && chmod a+r /etc/apt/keyrings/docker.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ - | tee /etc/apt/sources.list.d/docker.list > /dev/null \ - && apt-get update && apt-get install -y docker-ce-cli docker-buildx-plugin docker-compose-plugin \ - && rm -rf /var/lib/apt/lists/* - -# ───── 5. Terraform ───── -RUN wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com noble main" \ - | tee /etc/apt/sources.list.d/hashicorp.list \ - && apt-get update && apt-get install -y terraform \ - && rm -rf /var/lib/apt/lists/* - -# ───── 6. kubectl + helm ───── -RUN curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" \ - -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl \ - && curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - -# ───── 7. Language runtimes: Java, Ruby, Rust, .NET ───── -# Java (OpenJDK 21) + Ruby -RUN apt-get update && apt-get install -y --no-install-recommends \ - openjdk-21-jdk ruby-bundler \ - && rm -rf /var/lib/apt/lists/* - -# Rust (rustup minimal: rustc, cargo, rust-std, rustfmt, clippy) -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal -ENV PATH="/root/.cargo/bin:${PATH}" - -# .NET 8 SDK -RUN wget https://dot.net/v1/dotnet-install.sh -O /tmp/dotnet-install.sh \ - && chmod +x /tmp/dotnet-install.sh \ - && /tmp/dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet \ - && ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ - && rm /tmp/dotnet-install.sh - -# ───── 8. Editors, signing, utilities ───── -RUN apt-get update && apt-get install -y --no-install-recommends \ - vim nano \ - gpg pass zip rsync less htop \ - && rm -rf /var/lib/apt/lists/* - -# ───── 9. Database clients ───── -# PostgreSQL, MySQL, Redis -RUN apt-get update && apt-get install -y --no-install-recommends \ - postgresql-client mysql-client redis-tools \ - && rm -rf /var/lib/apt/lists/* - -# MS SQL Server tools (sqlcmd, bcp) -RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" \ - > /etc/apt/sources.list.d/mssql-release.list \ - && apt-get update && ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 unixodbc-dev \ - && rm -rf /var/lib/apt/lists/* -ENV PATH="/opt/mssql-tools18/bin:${PATH}" - -# ───── 10. Modern CLI enhancements + shell ───── -# bat (cat with syntax highlighting) — Ubuntu 24.04 has it -RUN apt-get update && apt-get install -y --no-install-recommends bat \ - && rm -rf /var/lib/apt/lists/* \ - && ln -sf /usr/bin/batcat /usr/bin/bat - -# delta (git diff viewer) — install via cargo (Rust already present) -RUN cargo install git-delta - -# zoxide (smart cd) — installer puts it in ~/.local/bin -RUN curl -fsSL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash -ENV PATH="/root/.local/bin:${PATH}" - -# direnv -RUN apt-get update && apt-get install -y --no-install-recommends direnv \ - && rm -rf /var/lib/apt/lists/* - -# starship (prompt) -RUN curl -fsSL https://starship.rs/install.sh | sh -s -- -y - -# zsh -RUN apt-get update && apt-get install -y --no-install-recommends zsh \ - && rm -rf /var/lib/apt/lists/* - -# ───── 11. Environment ───── -ENV TERM=xterm-256color -ENV LANG=C.UTF-8 -ENV EDITOR=vim diff --git a/internal/container/lifecycle.go b/internal/container/lifecycle.go deleted file mode 100644 index cb2d31ded..000000000 --- a/internal/container/lifecycle.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package container provides Docker container lifecycle management for hawk's -// sandboxed execution environments. It wraps the Docker CLI to start, stop, -// inspect, and rebuild containers. -package container - -import ( - "bytes" - "context" - "errors" - "fmt" - "os/exec" - "strings" - "time" -) - -// Error codes returned by ContainerError. -const ( - CodeNotFound = "not_found" - CodeNotRunning = "not_running" - CodeExecFailed = "exec_failed" - CodeTimeout = "timeout" -) - -// ContainerError is a typed error with a machine-readable Code field. -type ContainerError struct { - Code string - Message string -} - -func (e *ContainerError) Error() string { - return fmt.Sprintf("%s: %s", e.Code, e.Message) -} - -// State constants returned by Status. -const ( - StateRunning = "running" - StateStopped = "exited" - StateNotFound = "not_found" -) - -// dockerCmd is a function variable for exec.CommandContext, replaceable in tests. -var dockerCmd = exec.CommandContext - -// Status queries Docker for the current state of a container. -// Returns StateRunning, StateStopped, or StateNotFound. -func Status(ctx context.Context, containerID string) (string, error) { - if containerID == "" { - return StateNotFound, nil - } - - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - cmd := dockerCmd(ctx, "docker", "inspect", "--format", "{{.State.Status}}", containerID) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - // If inspect fails, the container doesn't exist. - errMsg := strings.TrimSpace(stderr.String()) - if strings.Contains(errMsg, "No such object") || strings.Contains(errMsg, "not found") { - return StateNotFound, nil - } - return StateNotFound, &ContainerError{ - Code: CodeNotFound, - Message: fmt.Sprintf("docker inspect: %s", errMsg), - } - } - - state := strings.TrimSpace(stdout.String()) - switch state { - case "running": - return StateRunning, nil - case "exited", "dead", "created": - return StateStopped, nil - default: - return state, nil - } -} - -// ExecResult holds the output of a command executed in a container. -type ExecResult struct { - Stdout string - Stderr string - ExitCode int -} - -// ExecWithStdin runs a command inside a container with stdin piped from the -// provided byte slice. Arguments are passed directly to docker exec without -// shell wrapping, making it safe for structured input (e.g. JSON). -func ExecWithStdin(ctx context.Context, containerID string, cmd []string, stdin []byte) (*ExecResult, error) { - if containerID == "" { - return nil, &ContainerError{Code: CodeNotFound, Message: "no container ID"} - } - - args := append([]string{"exec", "-i", containerID}, cmd...) - c := dockerCmd(ctx, "docker", args...) - c.Stdin = bytes.NewReader(stdin) - - var stdout, stderr bytes.Buffer - c.Stdout = &stdout - c.Stderr = &stderr - - err := c.Run() - if ctx.Err() == context.DeadlineExceeded { - return nil, &ContainerError{Code: CodeTimeout, Message: "command timed out"} - } - - exitCode := 0 - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } else { - return nil, &ContainerError{ - Code: CodeExecFailed, - Message: fmt.Sprintf("docker exec: %v", err), - } - } - } - - return &ExecResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), - ExitCode: exitCode, - }, nil -} - -// Rebuild stops and removes an existing container, then creates a new one -// from the specified image. The new container is started with sleep infinity -// and the given working directory mounted. -func Rebuild(ctx context.Context, containerID, newImage, workDir string) (string, error) { - // Stop and remove old container if it exists. - if containerID != "" { - rmCtx, rmCancel := context.WithTimeout(ctx, 10*time.Second) - defer rmCancel() - rm := dockerCmd(rmCtx, "docker", "rm", "-f", containerID) - _ = rm.Run() - } - - // Create new container. - createCtx, createCancel := context.WithTimeout(ctx, 120*time.Second) - defer createCancel() - - args := []string{ - "run", "-d", - "-v", workDir + ":" + workDir, - "-w", workDir, - newImage, - "sleep", "infinity", - } - c := dockerCmd(createCtx, "docker", args...) - var stdout, stderr bytes.Buffer - c.Stdout = &stdout - c.Stderr = &stderr - - if err := c.Run(); err != nil { - return "", &ContainerError{ - Code: CodeExecFailed, - Message: fmt.Sprintf("docker run: %s", strings.TrimSpace(stderr.String())), - } - } - - return strings.TrimSpace(stdout.String()), nil -} - -// EnsureRunning guarantees that the specified container is in a running state. -// If the container is stopped, it starts it. If it does not exist, it creates -// a new one from the given image with the working directory mounted. -// Returns the container ID (which may differ from input if recreated). -func EnsureRunning(ctx context.Context, containerID, image, workDir string) (string, error) { - state, err := Status(ctx, containerID) - if err != nil { - // If we can't determine state, try to create fresh. - return Rebuild(ctx, "", image, workDir) - } - - switch state { - case StateRunning: - return containerID, nil - - case StateStopped: - // Try to start the existing container. - startCtx, startCancel := context.WithTimeout(ctx, 30*time.Second) - defer startCancel() - start := dockerCmd(startCtx, "docker", "start", containerID) - var stderr bytes.Buffer - start.Stderr = &stderr - if err := start.Run(); err != nil { - // Start failed — rebuild from scratch. - return Rebuild(ctx, containerID, image, workDir) - } - return containerID, nil - - case StateNotFound: - return Rebuild(ctx, "", image, workDir) - - default: - // Unknown state — rebuild. - return Rebuild(ctx, containerID, image, workDir) - } -} diff --git a/internal/container/lifecycle_test.go b/internal/container/lifecycle_test.go deleted file mode 100644 index 1d8072581..000000000 --- a/internal/container/lifecycle_test.go +++ /dev/null @@ -1,350 +0,0 @@ -package container - -import ( - "bytes" - "context" - "errors" - "fmt" - "os/exec" - "strings" - "testing" -) - -// mockCmdWithRecorder returns a mock that records all invocations. -type invocation struct { - name string - args []string -} - -func mockCmdRecorder(responses []struct { - stdout string - stderr string - exitCode int -}, -) (func(ctx context.Context, name string, args ...string) *exec.Cmd, *[]invocation) { - var calls []invocation - idx := 0 - fn := func(ctx context.Context, name string, args ...string) *exec.Cmd { - calls = append(calls, invocation{name: name, args: args}) - r := responses[0] - if idx < len(responses) { - r = responses[idx] - idx++ - } - script := fmt.Sprintf("printf '%%s' %q >&1; printf '%%s' %q >&2; exit %d", - r.stdout, r.stderr, r.exitCode) - return exec.CommandContext(ctx, "/bin/sh", "-c", script) - } - return fn, &calls -} - -func TestContainerErrorCodes(t *testing.T) { - tests := []struct { - code string - msg string - want string - }{ - {CodeNotFound, "container gone", "not_found: container gone"}, - {CodeNotRunning, "stopped", "not_running: stopped"}, - {CodeExecFailed, "bad cmd", "exec_failed: bad cmd"}, - {CodeTimeout, "too slow", "timeout: too slow"}, - } - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - err := &ContainerError{Code: tt.code, Message: tt.msg} - if err.Error() != tt.want { - t.Errorf("got %q, want %q", err.Error(), tt.want) - } - }) - } -} - -func TestStatusParsing(t *testing.T) { - tests := []struct { - name string - stdout string - stderr string - exitCode int - wantState string - wantErrNil bool - }{ - { - name: "running container", - stdout: "running", - exitCode: 0, - wantState: StateRunning, - wantErrNil: true, - }, - { - name: "exited container", - stdout: "exited", - exitCode: 0, - wantState: StateStopped, - wantErrNil: true, - }, - { - name: "dead container", - stdout: "dead", - exitCode: 0, - wantState: StateStopped, - wantErrNil: true, - }, - { - name: "not found container", - stderr: "Error: No such object: abc123", - exitCode: 1, - wantState: StateNotFound, - wantErrNil: true, - }, - { - name: "empty container ID", - wantState: StateNotFound, - wantErrNil: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - orig := dockerCmd - defer func() { dockerCmd = orig }() - - if tt.name == "empty container ID" { - // No mock needed — Status returns early for empty ID. - state, err := Status(context.Background(), "") - if state != tt.wantState { - t.Errorf("state = %q, want %q", state, tt.wantState) - } - if (err == nil) != tt.wantErrNil { - t.Errorf("err = %v, wantErrNil = %v", err, tt.wantErrNil) - } - return - } - - dockerCmd = func(ctx context.Context, name string, args ...string) *exec.Cmd { - script := fmt.Sprintf("printf '%%s' %q >&1; printf '%%s' %q >&2; exit %d", - tt.stdout, tt.stderr, tt.exitCode) - return exec.CommandContext(ctx, "/bin/sh", "-c", script) - } - - state, err := Status(context.Background(), "test-container-123") - if state != tt.wantState { - t.Errorf("state = %q, want %q", state, tt.wantState) - } - if (err == nil) != tt.wantErrNil { - t.Errorf("err = %v, wantErrNil = %v", err, tt.wantErrNil) - } - }) - } -} - -func TestExecWithStdin(t *testing.T) { - orig := dockerCmd - defer func() { dockerCmd = orig }() - - t.Run("empty container ID", func(t *testing.T) { - _, err := ExecWithStdin(context.Background(), "", []string{"cat"}, []byte("hello")) - if err == nil { - t.Fatal("expected error for empty container ID") - } - ce := &ContainerError{} - ok := errors.As(err, &ce) - if !ok { - t.Fatalf("expected *ContainerError, got %T", err) - } - if ce.Code != CodeNotFound { - t.Errorf("code = %q, want %q", ce.Code, CodeNotFound) - } - }) - - t.Run("successful exec with stdin", func(t *testing.T) { - dockerCmd = func(ctx context.Context, name string, args ...string) *exec.Cmd { - // Verify that args contain the expected docker exec structure. - joined := strings.Join(args, " ") - if !strings.Contains(joined, "exec -i my-container cat") { - t.Errorf("unexpected args: %v", args) - } - // Simulate reading stdin and echoing it. - cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "cat") - cmd.Stdin = bytes.NewReader([]byte(`{"key":"value"}`)) - // We can't truly pipe through the mock, so just echo fixed output. - return exec.CommandContext(ctx, "/bin/sh", "-c", `printf '{"key":"value"}'`) - } - - result, err := ExecWithStdin(context.Background(), "my-container", []string{"cat"}, []byte(`{"key":"value"}`)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.ExitCode != 0 { - t.Errorf("exit code = %d, want 0", result.ExitCode) - } - if !strings.Contains(result.Stdout, `"key"`) { - t.Errorf("stdout = %q, expected JSON content", result.Stdout) - } - }) - - t.Run("non-zero exit code", func(t *testing.T) { - dockerCmd = func(ctx context.Context, name string, args ...string) *exec.Cmd { - return exec.CommandContext(ctx, "/bin/sh", "-c", "printf 'err' >&2; exit 42") - } - - result, err := ExecWithStdin(context.Background(), "my-container", []string{"false"}, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.ExitCode != 42 { - t.Errorf("exit code = %d, want 42", result.ExitCode) - } - }) -} - -func TestEnsureRunning(t *testing.T) { - orig := dockerCmd - defer func() { dockerCmd = orig }() - - t.Run("already running", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stdout: "running", exitCode: 0}, // Status inspect - } - fn, calls := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := EnsureRunning(context.Background(), "existing-id", "alpine:latest", "/workspace") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "existing-id" { - t.Errorf("id = %q, want %q", id, "existing-id") - } - // Only one call: docker inspect. - if len(*calls) != 1 { - t.Errorf("expected 1 call, got %d: %v", len(*calls), *calls) - } - }) - - t.Run("stopped container restarted", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stdout: "exited", exitCode: 0}, // Status inspect - {stdout: "", exitCode: 0}, // docker start - } - fn, calls := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := EnsureRunning(context.Background(), "stopped-id", "alpine:latest", "/workspace") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "stopped-id" { - t.Errorf("id = %q, want %q", id, "stopped-id") - } - if len(*calls) != 2 { - t.Errorf("expected 2 calls, got %d: %v", len(*calls), *calls) - } - }) - - t.Run("not found creates new", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stderr: "Error: No such object: gone", exitCode: 1}, // Status inspect - {stdout: "new-container-id-abc123", exitCode: 0}, // docker run - } - fn, calls := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := EnsureRunning(context.Background(), "gone", "alpine:latest", "/workspace") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "new-container-id-abc123" { - t.Errorf("id = %q, want %q", id, "new-container-id-abc123") - } - if len(*calls) < 2 { - t.Errorf("expected at least 2 calls, got %d: %v", len(*calls), *calls) - } - }) - - t.Run("empty ID creates new", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stdout: "fresh-container-id", exitCode: 0}, // docker run (Rebuild with empty containerID skips rm) - } - fn, _ := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := EnsureRunning(context.Background(), "", "alpine:latest", "/workspace") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "fresh-container-id" { - t.Errorf("id = %q, want %q", id, "fresh-container-id") - } - }) -} - -func TestRebuild(t *testing.T) { - orig := dockerCmd - defer func() { dockerCmd = orig }() - - t.Run("removes old and creates new", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stdout: "", exitCode: 0}, // docker rm -f - {stdout: "rebuilt-abc", exitCode: 0}, // docker run - } - fn, calls := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := Rebuild(context.Background(), "old-container", "ubuntu:24.04", "/project") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "rebuilt-abc" { - t.Errorf("id = %q, want %q", id, "rebuilt-abc") - } - // Should have called rm then run. - if len(*calls) != 2 { - t.Errorf("expected 2 calls, got %d", len(*calls)) - } - if !strings.Contains(strings.Join((*calls)[0].args, " "), "rm -f old-container") { - t.Errorf("first call should be rm -f, got: %v", (*calls)[0].args) - } - }) - - t.Run("skips rm when no old container", func(t *testing.T) { - responses := []struct { - stdout string - stderr string - exitCode int - }{ - {stdout: "new-id", exitCode: 0}, // docker run - } - fn, calls := mockCmdRecorder(responses) - dockerCmd = fn - - id, err := Rebuild(context.Background(), "", "alpine:latest", "/work") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != "new-id" { - t.Errorf("id = %q, want %q", id, "new-id") - } - if len(*calls) != 1 { - t.Errorf("expected 1 call, got %d", len(*calls)) - } - }) -} diff --git a/internal/context/frontmatter.go b/internal/context/frontmatter.go deleted file mode 100644 index 29ba576e4..000000000 --- a/internal/context/frontmatter.go +++ /dev/null @@ -1,194 +0,0 @@ -package context - -import ( - "path/filepath" - "strings" -) - -// RuleFrontmatter holds the YAML frontmatter parsed from a rule file. -type RuleFrontmatter struct { - Description string `yaml:"description"` - Globs []string `yaml:"globs"` - AlwaysApply *bool `yaml:"alwaysApply"` -} - -// ParseFrontmatter extracts YAML frontmatter from a markdown file. -// Returns the parsed frontmatter, the markdown body, and any error. -// If no frontmatter is present, returns nil frontmatter and the full content as body. -func ParseFrontmatter(content string) (*RuleFrontmatter, string) { - content = strings.TrimSpace(content) - if !strings.HasPrefix(content, "---") { - return nil, content - } - - // Find the closing --- - rest := content[3:] - endIdx := strings.Index(rest, "\n---") - if endIdx == -1 { - return nil, content - } - - yamlBlock := strings.TrimSpace(rest[:endIdx]) - body := strings.TrimSpace(rest[endIdx+4:]) - - fm := parseSimpleYAML(yamlBlock) - return fm, body -} - -// parseSimpleYAML does a minimal YAML parse of the frontmatter block. -// It handles the specific fields we care about (description, globs, alwaysApply) -// without requiring a full YAML library. -func parseSimpleYAML(block string) *RuleFrontmatter { - fm := &RuleFrontmatter{} - lines := strings.Split(block, "\n") - - inGlobs := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "description:") { - fm.Description = strings.TrimSpace(strings.TrimPrefix(trimmed, "description:")) - fm.Description = strings.Trim(fm.Description, "\"'") - inGlobs = false - continue - } - - if strings.HasPrefix(trimmed, "alwaysApply:") { - val := strings.TrimSpace(strings.TrimPrefix(trimmed, "alwaysApply:")) - b := val == "true" - fm.AlwaysApply = &b - inGlobs = false - continue - } - - if strings.HasPrefix(trimmed, "globs:") { - val := strings.TrimSpace(strings.TrimPrefix(trimmed, "globs:")) - if strings.HasPrefix(val, "[") { - // Inline array: globs: ["*.ts", "*.tsx"] - val = strings.Trim(val, "[]") - val = strings.ReplaceAll(val, "\"", "") - val = strings.ReplaceAll(val, "'", "") - for _, g := range strings.Split(val, ",") { - g = strings.TrimSpace(g) - if g != "" { - fm.Globs = append(fm.Globs, g) - } - } - inGlobs = false - } else { - // Multi-line list follows - inGlobs = true - } - continue - } - - if inGlobs && strings.HasPrefix(trimmed, "- ") { - g := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - g = strings.Trim(g, "\"'") - if g != "" { - fm.Globs = append(fm.Globs, g) - } - continue - } - - // Any other key resets globs context - if !strings.HasPrefix(trimmed, "-") && trimmed != "" { - inGlobs = false - } - } - - return fm -} - -// ShouldInject determines whether a rule file should be injected for a given -// file path, based on its frontmatter configuration. -// Rules are injected when: -// - alwaysApply is true (or nil) -// - alwaysApply is false AND the file matches one of the globs -// If no frontmatter is present, the rule is always applied. -func ShouldInject(fm *RuleFrontmatter, filePath string) bool { - if fm == nil { - return true - } - if fm.AlwaysApply == nil || *fm.AlwaysApply { - return true - } - if len(fm.Globs) == 0 { - return true - } - return MatchGlobs(fm.Globs, filePath) -} - -// MatchGlobs checks if a file path matches any of the given glob patterns. -// Supports standard filepath.Match patterns plus ** for directory matching. -func MatchGlobs(globs []string, filePath string) bool { - for _, glob := range globs { - if matchGlob(glob, filePath) { - return true - } - } - return false -} - -func matchGlob(glob, filePath string) bool { - // Normalize separators - glob = filepath.ToSlash(glob) - filePath = filepath.ToSlash(filePath) - - // Simple ** support: **/ matches any path prefix - if strings.Contains(glob, "**") { - return matchDoubleStar(glob, filePath) - } - - // Use filepath.Match for standard patterns - matched, _ := filepath.Match(glob, filePath) - if matched { - return true - } - // Also try matching just the basename - matched, _ = filepath.Match(glob, filepath.Base(filePath)) - return matched -} - -func matchDoubleStar(glob, filePath string) bool { - parts := strings.Split(glob, "**") - if len(parts) == 1 { - matched, _ := filepath.Match(glob, filePath) - return matched - } - - // For patterns like **/*.ts, match suffix - if len(parts) == 2 && parts[0] == "" { - suffix := strings.TrimPrefix(parts[1], "/") - // Match the suffix against the file path - return matchSuffix(suffix, filePath) - } - - // For patterns like src/**/*.ts - prefix := parts[0] - suffix := strings.TrimPrefix(parts[1], "/") - - if !strings.HasPrefix(filePath, strings.TrimSuffix(prefix, "/")) { - return false - } - remaining := strings.TrimPrefix(filePath, strings.TrimSuffix(prefix, "/")) - remaining = strings.TrimPrefix(remaining, "/") - return matchSuffix(suffix, remaining) -} - -func matchSuffix(pattern, path string) bool { - if !strings.Contains(pattern, "/") { - // Simple suffix like *.ts - match against any path component - return matchGlob(pattern, filepath.Base(path)) - } - // Path pattern - try matching against each suffix of the path - parts := strings.Split(filepath.ToSlash(path), "/") - for i := range parts { - candidate := strings.Join(parts[i:], "/") - matched, _ := filepath.Match(pattern, candidate) - if matched { - return true - } - } - return false -} diff --git a/internal/context/frontmatter_test.go b/internal/context/frontmatter_test.go deleted file mode 100644 index 8d981d5af..000000000 --- a/internal/context/frontmatter_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package context - -import ( - "testing" -) - -func TestParseFrontmatter_WithGlobs(t *testing.T) { - content := `--- -description: TypeScript defaults -globs: ["**/*.ts", "**/*.tsx"] -alwaysApply: false ---- - -Prefer strict TypeScript.` - - fm, body := ParseFrontmatter(content) - if fm == nil { - t.Fatal("expected frontmatter") - } - if fm.Description != "TypeScript defaults" { - t.Errorf("expected 'TypeScript defaults', got %q", fm.Description) - } - if len(fm.Globs) != 2 { - t.Errorf("expected 2 globs, got %d", len(fm.Globs)) - } - if fm.AlwaysApply == nil || *fm.AlwaysApply { - t.Error("expected alwaysApply=false") - } - if body != "Prefer strict TypeScript." { - t.Errorf("body mismatch: %q", body) - } -} - -func TestParseFrontmatter_NoFrontmatter(t *testing.T) { - content := "# Just a regular markdown file" - fm, body := ParseFrontmatter(content) - if fm != nil { - t.Error("expected nil frontmatter") - } - if body != content { - t.Errorf("body should equal content") - } -} - -func TestParseFrontmatter_MultilineGlobs(t *testing.T) { - content := `--- -description: Go rules -globs: - - "**/*.go" - - "**/go.mod" -alwaysApply: true ---- - -Use gofmt.` - - fm, body := ParseFrontmatter(content) - if fm == nil { - t.Fatal("expected frontmatter") - } - if len(fm.Globs) != 2 { - t.Errorf("expected 2 globs, got %d: %v", len(fm.Globs), fm.Globs) - } - if body != "Use gofmt." { - t.Errorf("body mismatch: %q", body) - } -} - -func TestParseFrontmatter_AlwaysApplyNil(t *testing.T) { - content := `--- -description: Always active ---- - -Content here.` - - fm, _ := ParseFrontmatter(content) - if fm == nil { - t.Fatal("expected frontmatter") - } - if fm.AlwaysApply != nil { - t.Error("expected alwaysApply to be nil when not specified") - } -} - -func TestShouldInject_AlwaysApplyTrue(t *testing.T) { - b := true - fm := &RuleFrontmatter{AlwaysApply: &b, Globs: []string{"*.ts"}} - if !ShouldInject(fm, "main.go") { - t.Error("alwaysApply=true should inject for any file") - } -} - -func TestShouldInject_GlobMatch(t *testing.T) { - b := false - fm := &RuleFrontmatter{AlwaysApply: &b, Globs: []string{"**/*.ts", "**/*.tsx"}} - - if !ShouldInject(fm, "src/app.ts") { - t.Error("should match *.ts") - } - if !ShouldInject(fm, "src/app.tsx") { - t.Error("should match *.tsx") - } - if ShouldInject(fm, "main.go") { - t.Error("should not match *.go") - } -} - -func TestShouldInject_NoFrontmatter(t *testing.T) { - if !ShouldInject(nil, "anything.go") { - t.Error("nil frontmatter should always inject") - } -} - -func TestShouldInject_EmptyGlobs(t *testing.T) { - b := false - fm := &RuleFrontmatter{AlwaysApply: &b} - if !ShouldInject(fm, "anything.go") { - t.Error("empty globs should always inject") - } -} - -func TestMatchGlobs_SimplePatterns(t *testing.T) { - tests := []struct { - globs []string - path string - want bool - }{ - {[]string{"*.go"}, "main.go", true}, - {[]string{"*.go"}, "main.ts", false}, - {[]string{"*.ts", "*.tsx"}, "app.tsx", true}, - {[]string{"*.json"}, "package.json", true}, - } - for _, tt := range tests { - if got := MatchGlobs(tt.globs, tt.path); got != tt.want { - t.Errorf("MatchGlobs(%v, %q) = %v, want %v", tt.globs, tt.path, got, tt.want) - } - } -} - -func TestMatchGlobs_DoubleStar(t *testing.T) { - tests := []struct { - glob string - path string - want bool - }{ - {"**/*.ts", "src/app.ts", true}, - {"**/*.ts", "app.ts", true}, - {"**/*.ts", "src/deep/nested/app.ts", true}, - {"**/*.ts", "main.go", false}, - {"src/**/*.go", "src/pkg/main.go", true}, - {"src/**/*.go", "pkg/main.go", false}, - } - for _, tt := range tests { - if got := matchGlob(tt.glob, tt.path); got != tt.want { - t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.glob, tt.path, got, tt.want) - } - } -} - -func TestMatchGlobs_PathPatterns(t *testing.T) { - tests := []struct { - glob string - path string - want bool - }{ - {"**/test/*.go", "src/test/main_test.go", true}, - {"**/test/*.go", "main.go", false}, - } - for _, tt := range tests { - if got := matchGlob(tt.glob, tt.path); got != tt.want { - t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.glob, tt.path, got, tt.want) - } - } -} diff --git a/internal/context/rules.go b/internal/context/rules.go deleted file mode 100644 index f22a5f02b..000000000 --- a/internal/context/rules.go +++ /dev/null @@ -1,293 +0,0 @@ -package context - -import ( - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "regexp" - "runtime" - "sort" - "strings" - - homepkg "github.com/GrayCodeAI/hawk/internal/home" - "github.com/GrayCodeAI/hawk/internal/storage" -) - -// managedSource is the synthetic source name for IT-managed (org policy) rules. -const managedSource = "managed" - -// defaultManagedPaths returns the platform-specific IT-managed policy file -// locations. These are intended to be writable only by administrators and take -// precedence over all user/project rules, so they cannot be overridden. -func defaultManagedPaths() []string { - switch runtime.GOOS { - case "darwin": - return []string{"/Library/Application Support/Hawk/HAWK.md"} - default: - // Linux and other unix-like systems. - return []string{"/etc/hawk/HAWK.md"} - } -} - -// htmlCommentRe matches HTML block comments (), including multi-line -// spans. It is non-greedy so adjacent comments are stripped independently. -var htmlCommentRe = regexp.MustCompile(`(?s)`) - -// stripHTMLComments removes all HTML block comments () from the -// given content before it is injected as rule/instruction context. -func stripHTMLComments(s string) string { - return htmlCommentRe.ReplaceAllString(s, "") -} - -// RuleSource defines a source of rule files with precedence. -type RuleSource struct { - Name string // directory or file name (e.g., ".agents/rules", "AGENTS.md") - Priority int // lower = higher priority - IsDir bool // true = scan directory recursively for *.md files -} - -// DefaultRuleSources lists rule sources in priority order. -var DefaultRuleSources = []RuleSource{ - {".agents/rules", 1, true}, - {".omo/rules", 2, true}, - {".claude/rules", 3, true}, - {".cursor/rules", 4, true}, - {".github/instructions", 5, true}, - {"AGENTS.md", 10, false}, - {"HAWK.md", 11, false}, - {"CLAUDE.md", 12, false}, - {"CONTEXT.md", 13, false}, - {".github/copilot-instructions.md", 14, false}, -} - -// Rule represents a discovered rule file with precedence metadata. -type Rule struct { - Path string - Content string - Hash string - Source string // e.g., ".agents/rules", "AGENTS.md" - Local bool // true if found in the file's directory tree, false if global - Distance int // 0 = same directory, 1 = parent, etc. - Priority int // source priority -} - -// RuleDiscoverer discovers rule files using walk-up stack semantics -// with 4-level precedence: local vs global → distance → source priority → path. -type RuleDiscoverer struct { - projectRoot string - sources []RuleSource - globalDirs []string // user-home rule directories - managedPaths []string // IT-managed (org policy) rule files; highest, non-excludable precedence - cache *InjectionCache -} - -// NewRuleDiscoverer creates a rule discoverer for the given project. -func NewRuleDiscoverer(projectRoot string) *RuleDiscoverer { - homeDir := homepkg.MustDir() - var globalDirs []string - if homeDir != "" { - globalDirs = []string{ - filepath.Join(storage.StateDir(), "rules"), - filepath.Join(homeDir, ".omo", "rules"), - filepath.Join(homeDir, ".claude", "rules"), - } - } - return &RuleDiscoverer{ - projectRoot: projectRoot, - sources: DefaultRuleSources, - globalDirs: globalDirs, - managedPaths: defaultManagedPaths(), - cache: NewInjectionCache(), - } -} - -// Discover finds all applicable rules for a file path, ordered by precedence. -func (rd *RuleDiscoverer) Discover(filePath string) []Rule { - dir := filepath.Dir(filePath) - if !filepath.IsAbs(dir) { - abs, err := filepath.Abs(dir) - if err != nil { - return nil - } - dir = abs - } - - var rules []Rule - - // Collect IT-managed (org policy) rules first — highest, non-excludable precedence. - managedRules := rd.collectManaged() - rules = append(rules, managedRules...) - - // Collect local rules (walk-up from file directory to project root) - localRules := rd.collectLocal(dir) - rules = append(rules, localRules...) - - // Collect global rules (user home) - globalRules := rd.collectGlobal() - rules = append(rules, globalRules...) - - // Deduplicate by content hash - rules = rd.dedup(rules) - - // Sort by precedence: local > global → distance > priority > path - sort.Slice(rules, func(i, j int) bool { - if rules[i].Local != rules[j].Local { - return rules[i].Local // local first - } - if rules[i].Distance != rules[j].Distance { - return rules[i].Distance < rules[j].Distance // closer first - } - if rules[i].Priority != rules[j].Priority { - return rules[i].Priority < rules[j].Priority // lower priority number = higher precedence - } - return rules[i].Path < rules[j].Path - }) - - return rules -} - -func (rd *RuleDiscoverer) collectLocal(dir string) []Rule { - var rules []Rule - currentDir := dir - - for { - for _, src := range rd.sources { - if src.IsDir { - rules = append(rules, rd.scanDir(currentDir, src)...) - } else { - rule := rd.checkFile(currentDir, src) - if rule != nil { - rules = append(rules, *rule) - } - } - } - - if currentDir == rd.projectRoot || currentDir == filepath.Dir(currentDir) { - break - } - currentDir = filepath.Dir(currentDir) - } - return rules -} - -// collectManaged loads IT-managed (org policy) rule files. These are given the -// highest precedence (Local=true, Distance/Priority below any real rule) so they -// sort ahead of all user/project/global rules and cannot be overridden. -func (rd *RuleDiscoverer) collectManaged() []Rule { - var rules []Rule - for _, path := range rd.managedPaths { - data, err := os.ReadFile(path) // #nosec G304 -- path comes from configured managed (org policy) rule locations, not external input - if err != nil { - continue - } - content := stripHTMLComments(string(data)) - rules = append(rules, Rule{ - Path: path, - Content: content, - Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(content))), - Source: managedSource, - Local: true, - Distance: -1, // sorts before distance 0 (closest local rule) - Priority: -1, // sorts before priority 1 (highest source priority) - }) - } - return rules -} - -func (rd *RuleDiscoverer) collectGlobal() []Rule { - var rules []Rule - for _, globalDir := range rd.globalDirs { - entries, err := os.ReadDir(globalDir) - if err != nil { - continue - } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { - continue - } - path := filepath.Join(globalDir, entry.Name()) - data, err := os.ReadFile(path) // #nosec G304 -- path is built from a trusted global rules directory listing (os.ReadDir entries), not external input - if err != nil { - continue - } - content := stripHTMLComments(string(data)) - rules = append(rules, Rule{ - Path: path, - Content: content, - Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(content))), - Source: filepath.Base(globalDir), - Local: false, - Distance: 999, - Priority: 100, - }) - } - } - return rules -} - -func (rd *RuleDiscoverer) scanDir(baseDir string, src RuleSource) []Rule { - rulesDir := filepath.Join(baseDir, src.Name) - entries, err := os.ReadDir(rulesDir) - if err != nil { - return nil - } - var rules []Rule - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") && !strings.HasSuffix(entry.Name(), ".mdc") { - continue - } - path := filepath.Join(rulesDir, entry.Name()) - data, err := os.ReadFile(path) // #nosec G304 -- path is built from a trusted rules directory listing (os.ReadDir entries), not external input - if err != nil { - continue - } - content := stripHTMLComments(string(data)) - rules = append(rules, Rule{ - Path: path, - Content: content, - Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(content))), - Source: src.Name, - Local: true, - Distance: dirLevel(rd.projectRoot, baseDir), - Priority: src.Priority, - }) - } - return rules -} - -func (rd *RuleDiscoverer) checkFile(baseDir string, src RuleSource) *Rule { - path := filepath.Join(baseDir, src.Name) - data, err := os.ReadFile(path) // #nosec G304 -- path is built from fixed configured rule source names, not external input - if err != nil { - return nil - } - content := stripHTMLComments(string(data)) - return &Rule{ - Path: path, - Content: content, - Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(content))), - Source: src.Name, - Local: true, - Distance: dirLevel(rd.projectRoot, baseDir), - Priority: src.Priority, - } -} - -func (rd *RuleDiscoverer) dedup(rules []Rule) []Rule { - seen := make(map[string]bool) - var result []Rule - for _, r := range rules { - if seen[r.Hash] { - continue - } - seen[r.Hash] = true - result = append(result, r) - } - return result -} - -// Cache returns the injection cache. -func (rd *RuleDiscoverer) Cache() *InjectionCache { - return rd.cache -} diff --git a/internal/context/rules_test.go b/internal/context/rules_test.go deleted file mode 100644 index fe171e38e..000000000 --- a/internal/context/rules_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package context - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRuleDiscoverer_FindsAgentsMd(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Root Rules"), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - found := false - for _, r := range rules { - if r.Source == "AGENTS.md" { - found = true - if r.Content != "# Root Rules" { - t.Errorf("content mismatch: got %q", r.Content) - } - } - } - if !found { - t.Error("AGENTS.md not found") - } -} - -func TestRuleDiscoverer_Precedence(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Root"), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte("# Src"), 0o644) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - if len(rules) < 2 { - t.Fatalf("expected at least 2 rules, got %d", len(rules)) - } - // Both are local, same source priority — ordered by distance (root=0 first, src=1 second) - // The root file is closer to the project root (distance 0) so it comes first - if rules[0].Content != "# Root" { - t.Errorf("expected root rule first (distance 0), got %q", rules[0].Content) - } - if rules[1].Content != "# Src" { - t.Errorf("expected src rule second (distance 1), got %q", rules[1].Content) - } -} - -func TestRuleDiscoverer_Deduplication(t *testing.T) { - dir := t.TempDir() - sameContent := "# Same content everywhere" - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(sameContent), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte(sameContent), 0o644) - - rd := NewRuleDiscoverer(dir) - // Isolate from host global/managed rule dirs (e.g. ~/.claude/rules) so the - // test is hermetic and only sees the project rules under test. - rd.globalDirs = nil - rd.managedPaths = nil - rules := rd.Discover(filepath.Join(sub, "main.go")) - - // Same content hash → deduped to 1 - if len(rules) != 1 { - t.Errorf("expected 1 after dedup, got %d", len(rules)) - } -} - -func TestRuleDiscoverer_DirectorySources(t *testing.T) { - dir := t.TempDir() - rulesDir := filepath.Join(dir, ".agents", "rules") - os.MkdirAll(rulesDir, 0o755) - os.WriteFile(filepath.Join(rulesDir, "naming.md"), []byte("# Naming Conventions"), 0o644) - os.WriteFile(filepath.Join(rulesDir, "testing.md"), []byte("# Testing Rules"), 0o644) - - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - found := 0 - for _, r := range rules { - if r.Source == ".agents/rules" { - found++ - } - } - if found != 2 { - t.Errorf("expected 2 rules from .agents/rules, got %d", found) - } -} - -func TestRuleDiscoverer_LocalVsGlobal(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Local"), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - // Add a fake global rule - rd.globalDirs = []string{filepath.Join(dir, "global-rules")} - os.MkdirAll(filepath.Join(dir, "global-rules"), 0o755) - os.WriteFile(filepath.Join(dir, "global-rules", "base.md"), []byte("# Global"), 0o644) - - rules := rd.Discover(target) - - // Local should come before global - lastLocal := -1 - firstGlobal := len(rules) - for i, r := range rules { - if r.Local { - lastLocal = i - } else if firstGlobal == len(rules) { - firstGlobal = i - } - } - if lastLocal > firstGlobal { - t.Error("local rules should come before global rules") - } -} - -func TestRuleDiscoverer_SourcePriority(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, ".agents", "rules"), 0o755) - os.MkdirAll(filepath.Join(dir, ".claude", "rules"), 0o755) - os.WriteFile(filepath.Join(dir, ".agents", "rules", "a.md"), []byte("# Hawk"), 0o644) - os.WriteFile(filepath.Join(dir, ".claude", "rules", "b.md"), []byte("# Claude"), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - // .agents/rules (priority 1) should come before .claude/rules (priority 3) - var hawkIdx, claudeIdx int - for i, r := range rules { - if r.Source == ".agents/rules" { - hawkIdx = i - } - if r.Source == ".claude/rules" { - claudeIdx = i - } - } - if hawkIdx >= claudeIdx { - t.Error(".agents/rules should have higher precedence than .claude/rules") - } -} - -func TestRuleDiscoverer_EmptyProject(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - // Isolate from host global/managed rule dirs (e.g. ~/.claude/rules) so the - // test is hermetic and only sees the project rules under test. - rd.globalDirs = nil - rd.managedPaths = nil - rules := rd.Discover(target) - if len(rules) != 0 { - t.Errorf("expected 0 rules in empty project, got %d", len(rules)) - } -} - -func TestRuleDiscoverer_ManagedTierPrecedence(t *testing.T) { - dir := t.TempDir() - // A project rule that would normally have top precedence. - os.WriteFile(filepath.Join(dir, "HAWK.md"), []byte("# Project Policy"), 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - // Stand in for the IT-managed policy file (default paths are system-level). - managed := filepath.Join(dir, "managed-HAWK.md") - os.WriteFile(managed, []byte("# Org Policy"), 0o644) - - rd := NewRuleDiscoverer(dir) - rd.managedPaths = []string{managed} - - rules := rd.Discover(target) - if len(rules) < 2 { - t.Fatalf("expected at least 2 rules (managed + project), got %d", len(rules)) - } - if rules[0].Source != managedSource { - t.Fatalf("expected managed rule first, got source %q content %q", rules[0].Source, rules[0].Content) - } - if rules[0].Content != "# Org Policy" { - t.Errorf("managed content mismatch: got %q", rules[0].Content) - } - // Managed must outrank the project HAWK.md regardless of project precedence. - for i, r := range rules { - if r.Source == "HAWK.md" && i == 0 { - t.Error("project HAWK.md should not outrank managed tier") - } - } -} - -func TestRuleDiscoverer_ManagedTierMissing(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "HAWK.md"), []byte("# Project"), 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rd.managedPaths = []string{filepath.Join(dir, "does-not-exist.md")} - - rules := rd.Discover(target) - for _, r := range rules { - if r.Source == managedSource { - t.Error("no managed rule should be produced when the file is absent") - } - } -} - -func TestStripHTMLComments(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"none", "# Title\nbody", "# Title\nbody"}, - {"inline", "a b", "a b"}, - {"multiline", "head\n\ntail", "head\n\ntail"}, - {"multiple", "keep", "keep"}, - {"only", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := stripHTMLComments(tt.in); got != tt.want { - t.Errorf("stripHTMLComments(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func TestRuleDiscoverer_StripsHTMLCommentsOnLoad(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "HAWK.md"), []byte("# Rules\n\nUse tabs."), 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - found := false - for _, r := range rules { - if r.Source == "HAWK.md" { - found = true - if strings.Contains(r.Content, "internal note") { - t.Errorf("HTML comment not stripped from loaded content: %q", r.Content) - } - if !strings.Contains(r.Content, "Use tabs.") { - t.Errorf("non-comment content lost: %q", r.Content) - } - } - } - if !found { - t.Fatal("HAWK.md rule not loaded") - } -} - -func TestRuleDiscoverer_NestedDirSources(t *testing.T) { - dir := t.TempDir() - // Create .github/instructions at root - ghDir := filepath.Join(dir, ".github", "instructions") - os.MkdirAll(ghDir, 0o755) - os.WriteFile(filepath.Join(ghDir, "pr.md"), []byte("# PR Guidelines"), 0o644) - - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - target := filepath.Join(sub, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - rd := NewRuleDiscoverer(dir) - rules := rd.Discover(target) - - found := false - for _, r := range rules { - if r.Source == ".github/instructions" { - found = true - } - } - if !found { - t.Error("expected rules from .github/instructions") - } -} diff --git a/internal/context/walkup.go b/internal/context/walkup.go deleted file mode 100644 index 8838ca992..000000000 --- a/internal/context/walkup.go +++ /dev/null @@ -1,263 +0,0 @@ -// Package context provides hierarchical context discovery for hawk. -// It implements walk-up AGENTS.md discovery (pi-nested-agents-md pattern): -// when an agent reads a file, the discoverer traverses upward collecting -// convention files at each directory level with session-scoped deduplication. -package context - -import ( - "crypto/sha256" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/GrayCodeAI/hawk/internal/hooks" -) - -const ( - defaultMaxFileKB = 32 - defaultMaxTotalKB = 128 -) - -// DefaultConventionFiles lists the files to discover at each directory level. -var DefaultConventionFiles = []string{ - "AGENTS.md", "HAWK.md", "CLAUDE.md", "CONTEXT.md", -} - -// InjectionCache tracks which files have already been injected this session. -type InjectionCache struct { - mu sync.Mutex - entries map[string]string // path -> content hash -} - -// NewInjectionCache creates a new injection cache. -func NewInjectionCache() *InjectionCache { - return &InjectionCache{entries: make(map[string]string)} -} - -// IsSeen returns true if the file at path with the given content hash -// has already been injected in this session. -func (c *InjectionCache) IsSeen(path, hash string) bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.entries[path] == hash -} - -// Mark records a file as injected. -func (c *InjectionCache) Mark(path, hash string) { - c.mu.Lock() - defer c.mu.Unlock() - c.entries[path] = hash -} - -// Clear removes all entries from the cache. -func (c *InjectionCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - c.entries = make(map[string]string) -} - -// Len returns the number of cached entries. -func (c *InjectionCache) Len() int { - c.mu.Lock() - defer c.mu.Unlock() - return len(c.entries) -} - -// WalkUpDiscoverer discovers convention files by walking up from a file's directory -// to the project root. It hooks into file read events and injects discovered -// conventions as additional context. -type WalkUpDiscoverer struct { - projectRoot string - cache *InjectionCache - maxFileKB int - maxTotalKB int - fileNames []string - pending []string // accumulated convention content - mu sync.Mutex -} - -// NewWalkUpDiscoverer creates a discoverer for the given project root. -func NewWalkUpDiscoverer(projectRoot string) *WalkUpDiscoverer { - return &WalkUpDiscoverer{ - projectRoot: projectRoot, - cache: NewInjectionCache(), - maxFileKB: defaultMaxFileKB, - maxTotalKB: defaultMaxTotalKB, - fileNames: DefaultConventionFiles, - } -} - -// WithMaxFileKB sets the max size per convention file (default 32KB). -func (d *WalkUpDiscoverer) WithMaxFileKB(kb int) *WalkUpDiscoverer { - d.maxFileKB = kb - return d -} - -// WithMaxTotalKB sets the max total injection size per event (default 128KB). -func (d *WalkUpDiscoverer) WithMaxTotalKB(kb int) *WalkUpDiscoverer { - d.maxTotalKB = kb - return d -} - -// WithFileNames sets the convention file names to look for. -func (d *WalkUpDiscoverer) WithFileNames(names []string) *WalkUpDiscoverer { - d.fileNames = names - return d -} - -// Discover walks up from filePath to projectRoot, collecting undiscovered convention files. -// Returns the content of newly discovered files (already deduplicated and truncated). -func (d *WalkUpDiscoverer) Discover(filePath string) []ConventionFile { - dir := filepath.Dir(filePath) - if !filepath.IsAbs(dir) { - abs, err := filepath.Abs(dir) - if err != nil { - return nil - } - dir = abs - } - - var discovered []ConventionFile - totalBytes := 0 - maxTotal := d.maxTotalKB * 1024 - maxFile := d.maxFileKB * 1024 - - // Walk from file's directory up to project root - for { - for _, name := range d.fileNames { - candidate := filepath.Join(dir, name) - data, err := os.ReadFile(candidate) // #nosec G304 -- candidate is built from a fixed set of convention file names joined with the walked-up directory, not external input - if err != nil { - continue // file doesn't exist at this level - } - - absCandidate, _ := filepath.Abs(candidate) - hash := fmt.Sprintf("%x", sha256.Sum256(data)) - - // Skip if already injected this session - if d.cache.IsSeen(absCandidate, hash) { - continue - } - - // Truncate if too large - content := string(data) - if len(content) > maxFile { - content = content[:maxFile] + "\n... (truncated)" - } - - // Check total budget - if totalBytes+len(content) > maxTotal { - break - } - - totalBytes += len(content) - d.cache.Mark(absCandidate, hash) - discovered = append(discovered, ConventionFile{ - Path: absCandidate, - Content: content, - Level: dirLevel(d.projectRoot, dir), - FileName: name, - }) - } - - // Stop if we've reached the project root - if dir == d.projectRoot || dir == filepath.Dir(dir) { - break - } - dir = filepath.Dir(dir) - } - - return discovered -} - -// ConventionFile represents a discovered convention file. -type ConventionFile struct { - Path string - Content string - Level int // 0 = project root, 1 = one level down, etc. - FileName string // e.g., "AGENTS.md" -} - -// HandlePostTool is a hook handler for EventPostTool that discovers conventions -// when a file is read. -func (d *WalkUpDiscoverer) HandlePostTool(ctx interface{}, envelope hooks.EventEnvelope) error { - // Only trigger on file read events - toolName, _ := envelope.Payload["tool"].(string) - if toolName != "Read" && toolName != "file_read" && toolName != "FileRead" { - return nil - } - - // Extract file path from payload - path := extractFilePath(envelope.Payload) - if path == "" { - return nil - } - - discovered := d.Discover(path) - if len(discovered) == 0 { - return nil - } - - d.mu.Lock() - for _, cf := range discovered { - d.pending = append(d.pending, formatConvention(cf)) - } - d.mu.Unlock() - - slog.Debug("walkup: discovered conventions", "count", len(discovered), "file", path) - return nil -} - -// HandleCompact is a hook handler for EventPostCompact that clears the cache. -func (d *WalkUpDiscoverer) HandleCompact(ctx interface{}, envelope hooks.EventEnvelope) error { - d.cache.Clear() - d.mu.Lock() - d.pending = nil - d.mu.Unlock() - slog.Debug("walkup: cache cleared after compaction") - return nil -} - -// FlushPending returns accumulated convention content and clears the pending buffer. -func (d *WalkUpDiscoverer) FlushPending() string { - d.mu.Lock() - defer d.mu.Unlock() - if len(d.pending) == 0 { - return "" - } - result := "--- DISCOVERED CONVENTIONS ---\n" + strings.Join(d.pending, "\n---\n") + "\n--- END CONVENTIONS ---" - d.pending = nil - return result -} - -// Cache returns the injection cache (for testing). -func (d *WalkUpDiscoverer) Cache() *InjectionCache { - return d.cache -} - -func extractFilePath(payload map[string]interface{}) string { - for _, key := range []string{"path", "file_path", "filePath", "file"} { - if v, ok := payload[key].(string); ok && v != "" { - return v - } - } - return "" -} - -func formatConvention(cf ConventionFile) string { - return fmt.Sprintf("[%s] (%s)\n%s", cf.FileName, cf.Path, cf.Content) -} - -func dirLevel(root, dir string) int { - rel, err := filepath.Rel(root, dir) - if err != nil { - return 0 - } - if rel == "." { - return 0 - } - return len(strings.Split(filepath.ToSlash(rel), "/")) -} diff --git a/internal/context/walkup_test.go b/internal/context/walkup_test.go deleted file mode 100644 index f8c4a09be..000000000 --- a/internal/context/walkup_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package context - -import ( - "os" - "path/filepath" - "testing" - - "github.com/GrayCodeAI/hawk/internal/hooks" -) - -func TestWalkUpDiscoverer_FindsAgentsMd(t *testing.T) { - dir := t.TempDir() - // Create AGENTS.md at project root - agentsMd := "# Project Rules\nUse gofmt." - if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(agentsMd), 0o644); err != nil { - t.Fatal(err) - } - // Create a subdirectory - sub := filepath.Join(dir, "src", "pkg") - if err := os.MkdirAll(sub, 0o755); err != nil { - t.Fatal(err) - } - // Read a file deep in the tree - target := filepath.Join(sub, "main.go") - if err := os.WriteFile(target, []byte("package main"), 0o644); err != nil { - t.Fatal(err) - } - - d := NewWalkUpDiscoverer(dir) - discovered := d.Discover(target) - if len(discovered) == 0 { - t.Fatal("expected to discover AGENTS.md") - } - found := false - for _, cf := range discovered { - if cf.FileName == "AGENTS.md" { - found = true - if cf.Content != agentsMd { - t.Errorf("content mismatch: got %q", cf.Content) - } - } - } - if !found { - t.Error("AGENTS.md not found in discovered files") - } -} - -func TestWalkUpDiscoverer_Deduplication(t *testing.T) { - dir := t.TempDir() - agentsMd := "# Rules" - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(agentsMd), 0o644) - sub := filepath.Join(dir, "a", "b") - os.MkdirAll(sub, 0o755) - f1 := filepath.Join(sub, "f1.go") - f2 := filepath.Join(sub, "f2.go") - os.WriteFile(f1, []byte("package a"), 0o644) - os.WriteFile(f2, []byte("package b"), 0o644) - - d := NewWalkUpDiscoverer(dir) - - // First discover - d1 := d.Discover(f1) - if len(d1) == 0 { - t.Fatal("expected to discover AGENTS.md on first call") - } - - // Second discover — should be empty (already cached) - d2 := d.Discover(f2) - if len(d2) != 0 { - t.Errorf("expected 0 on second call (dedup), got %d", len(d2)) - } -} - -func TestWalkUpDiscoverer_MultipleLevels(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Root"), 0o644) - sub := filepath.Join(dir, "src") - os.MkdirAll(sub, 0o755) - os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte("# Src Rules"), 0o644) - deep := filepath.Join(sub, "pkg") - os.MkdirAll(deep, 0o755) - target := filepath.Join(deep, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - d := NewWalkUpDiscoverer(dir) - discovered := d.Discover(target) - - if len(discovered) < 2 { - t.Fatalf("expected at least 2 discoveries (root + src), got %d", len(discovered)) - } -} - -func TestWalkUpDiscoverer_NonExistentFile(t *testing.T) { - dir := t.TempDir() - d := NewWalkUpDiscoverer(dir) - discovered := d.Discover(filepath.Join(dir, "nonexistent.go")) - if len(discovered) != 0 { - t.Errorf("expected 0 discoveries for nonexistent file, got %d", len(discovered)) - } -} - -func TestWalkUpDiscoverer_Truncation(t *testing.T) { - dir := t.TempDir() - bigContent := make([]byte, 100*1024) // 100KB - for i := range bigContent { - bigContent[i] = 'x' - } - os.WriteFile(filepath.Join(dir, "AGENTS.md"), bigContent, 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - d := NewWalkUpDiscoverer(dir).WithMaxFileKB(10) // 10KB cap - discovered := d.Discover(target) - if len(discovered) == 0 { - t.Fatal("expected to discover AGENTS.md") - } - if len(discovered[0].Content) > 10*1024+50 { // 10KB + truncation message - t.Errorf("content too large: %d bytes", len(discovered[0].Content)) - } -} - -func TestWalkUpDiscoverer_HandlePostTool(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Rules"), 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - d := NewWalkUpDiscoverer(dir) - envelope := hooks.EventEnvelope{ - Payload: map[string]interface{}{ - "tool": "Read", - "path": target, - }, - } - err := d.HandlePostTool(nil, envelope) - if err != nil { - t.Fatalf("HandlePostTool: %v", err) - } - pending := d.FlushPending() - if pending == "" { - t.Error("expected pending conventions after HandlePostTool") - } -} - -func TestWalkUpDiscoverer_HandlePostTool_IgnoresNonRead(t *testing.T) { - dir := t.TempDir() - d := NewWalkUpDiscoverer(dir) - envelope := hooks.EventEnvelope{ - Payload: map[string]interface{}{ - "tool": "Bash", - "cmd": "ls", - }, - } - err := d.HandlePostTool(nil, envelope) - if err != nil { - t.Fatalf("HandlePostTool: %v", err) - } - if pending := d.FlushPending(); pending != "" { - t.Error("expected no pending for non-read tools") - } -} - -func TestWalkUpDiscoverer_CompactClearsCache(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Rules"), 0o644) - target := filepath.Join(dir, "main.go") - os.WriteFile(target, []byte("package main"), 0o644) - - d := NewWalkUpDiscoverer(dir) - d.Discover(target) - if d.Cache().Len() == 0 { - t.Fatal("expected cache to have entries") - } - - d.HandleCompact(nil, hooks.EventEnvelope{}) - if d.Cache().Len() != 0 { - t.Error("expected cache to be cleared after compaction") - } -} - -func TestInjectionCache(t *testing.T) { - c := NewInjectionCache() - if c.IsSeen("/foo", "hash1") { - t.Error("should not be seen initially") - } - c.Mark("/foo", "hash1") - if !c.IsSeen("/foo", "hash1") { - t.Error("should be seen after mark") - } - if c.IsSeen("/foo", "hash2") { - t.Error("different hash should not be seen") - } - c.Clear() - if c.IsSeen("/foo", "hash1") { - t.Error("should not be seen after clear") - } -} - -func TestDirLevel(t *testing.T) { - tests := []struct { - root, dir string - want int - }{ - {"/project", "/project", 0}, - {"/project", "/project/src", 1}, - {"/project", "/project/src/pkg", 2}, - } - for _, tt := range tests { - if got := dirLevel(tt.root, tt.dir); got != tt.want { - t.Errorf("dirLevel(%q, %q) = %d, want %d", tt.root, tt.dir, got, tt.want) - } - } -} diff --git a/internal/deps/deps.go b/internal/deps/deps.go deleted file mode 100644 index 7e594fc9f..000000000 --- a/internal/deps/deps.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package deps documents the dependency injection pattern used across hawk. -// -// # Pattern -// -// Each package that needs external dependencies defines a Deps struct with -// exported fields for each dependency. A zero value means "use default". -// Constructors accept the deps struct and fill in defaults for zero fields. -// -// Example: -// -// type LoggerDeps struct { -// Output io.Writer // default: os.Stderr -// Level Level // default: Info -// } -// -// func NewWithDeps(deps LoggerDeps) *Logger { -// if deps.Output == nil { -// deps.Output = os.Stderr -// } -// return &Logger{level: deps.Level, output: deps.Output} -// } -// -// Tests override individual fields: -// -// var buf bytes.Buffer -// log := logger.NewWithDeps(logger.LoggerDeps{Output: &buf}) -// -// This replaces positional constructor arguments and makes testing explicit. -// Keep legacy constructors (e.g., New(output, level)) as convenience wrappers -// that call NewWithDeps internally. -package deps diff --git a/internal/engine/async/engine.go b/internal/engine/async/engine.go deleted file mode 100644 index 4b0aa1330..000000000 --- a/internal/engine/async/engine.go +++ /dev/null @@ -1,283 +0,0 @@ -package async - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/google/uuid" - - "github.com/GrayCodeAI/hawk/internal/engine" -) - -// Engine wraps an engine.Session with queue-based async operation. -// Submissions are processed in order; events are broadcast to all subscribers. -// -// Lifecycle: Start spawns exactly one processing loop; Stop cancels it and -// waits for it to exit. Start after Stop is safe — a fresh loop is created. -// Submissions queued after Stop are never processed (documented: check the -// engine state before submitting). -type Engine struct { - session *engine.Session - subQ *SubmissionQueue - evtQ *EventQueue - mu sync.Mutex - running bool - wg sync.WaitGroup - stop context.CancelFunc - turnMu sync.Mutex - turn context.CancelFunc -} - -// New creates an async engine wrapping the given session. -func New(sess *engine.Session) *Engine { - return &Engine{ - session: sess, - subQ: NewSubmissionQueue(), - evtQ: NewEventQueue(5000), - } -} - -// Start begins processing submissions in the background. It is safe to call -// multiple times; only the first call spawns the loop. After Stop, a new -// Start spawns a fresh loop. -func (e *Engine) Start(ctx context.Context) { - e.mu.Lock() - if e.running { - e.mu.Unlock() - return - } - e.running = true - loopCtx, cancel := context.WithCancel(ctx) - e.stop = cancel - e.wg.Add(1) - e.mu.Unlock() - - go e.loop(loopCtx) -} - -// Stop gracefully stops the engine. It cancels the processing loop and waits -// (with a bounded grace period) for it to exit, so a subsequent Start can -// never run two loops over the same queue. Idempotent. -func (e *Engine) Stop() { - e.mu.Lock() - if !e.running { - e.mu.Unlock() - return - } - e.running = false - stop := e.stop - e.stop = nil - e.mu.Unlock() - - if stop != nil { - stop() - } - // Bounded wait: the loop may be mid-Stream (provider call); do not block - // shutdown forever, but still prevent double-loops in the common case. - done := make(chan struct{}) - go func() { - e.wg.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(5 * time.Second): - } -} - -// Submit enqueues a user turn for processing. -func (e *Engine) Submit(message string) *Submission { - s := &Submission{ - ID: uuid.NewString(), - Op: OpUserTurn, - Payload: message, - Timestamp: time.Now(), - } - e.subQ.Submit(s) - return s -} - -// Cancel aborts the in-flight turn immediately. No-op when the engine is -// idle. Direct (not queued): the processing loop is single-threaded and -// blocked inside the turn's stream, so a queued cancel would never be -// popped until the turn ended anyway. -func (e *Engine) Cancel() { - e.cancel() -} - -// cancel aborts the in-flight turn only; the processing loop keeps running. -func (e *Engine) cancel() { - e.turnMu.Lock() - c := e.turn - e.turnMu.Unlock() - if c != nil { - c() - } -} - -// Events returns a channel for consuming events. Unsubscribe with -// e.Unsubscribe(ch) when done; subscriber channels are never closed by the -// engine (the terminal EventDone/EventError marks the end of a turn). -func (e *Engine) Events() <-chan *Event { - return e.evtQ.Subscribe() -} - -// Unsubscribe removes a subscriber channel so it stops receiving events. -func (e *Engine) Unsubscribe(ch <-chan *Event) { - e.evtQ.Unsubscribe(ch) -} - -// Replay returns all events since the engine started. -func (e *Engine) Replay() []*Event { - return e.evtQ.Replay() -} - -// Session returns the underlying engine session. -func (e *Engine) Session() *engine.Session { - return e.session -} - -func (e *Engine) loop(ctx context.Context) { - defer e.wg.Done() - for { - select { - case <-ctx.Done(): - return - case <-e.subQ.notify: - for { - sub, ok := e.subQ.pop() - if !ok { - break - } - e.process(ctx, sub) - } - } - } -} - -func (e *Engine) process(ctx context.Context, sub *Submission) { - switch sub.Op { - case OpUserTurn: - e.processTurn(ctx, sub.Payload, sub.ReplyTo) - case OpCancel, OpInterrupt: - // Cancellation is applied directly by Engine.Cancel (the loop is - // single-threaded, so a queued cancel could not run until the turn - // ended). This path exists for queue compatibility only. - e.cancel() - case OpResume: - // Resume will be implemented with session replay support. - default: - e.evtQ.Push(&Event{ - ID: uuid.NewString(), - Type: EventError, - Content: fmt.Sprintf("unknown op: %s", sub.Op), - }) - } -} - -// processTurn runs one agent turn. The terminal event is EventDone on both -// success and stream error paths, so consumers can always wait for it; the -// error detail (if any) arrives as an EventError before EventDone. -func (e *Engine) processTurn(ctx context.Context, message string, replyTo chan<- *Event) { - e.session.AddUser(message) - - turnCtx, cancel := context.WithCancel(ctx) - e.turnMu.Lock() - e.turn = cancel - e.turnMu.Unlock() - defer func() { - e.turnMu.Lock() - e.turn = nil - e.turnMu.Unlock() - cancel() - }() - - stream, err := e.session.Stream(turnCtx) - if err != nil { - errEvt := &Event{ - ID: uuid.NewString(), - Type: EventError, - Content: err.Error(), - } - e.evtQ.Push(errEvt) - notifyReply(replyTo, errEvt) - doneEvt := &Event{ID: uuid.NewString(), Type: EventDone} - e.evtQ.Push(doneEvt) - notifyReply(replyTo, doneEvt) - return - } - - for evt := range stream { - asyncEvt := toAsyncEvent(evt) - e.evtQ.Push(asyncEvt) - } - - // Signal done. - doneEvt := &Event{ID: uuid.NewString(), Type: EventDone} - e.evtQ.Push(doneEvt) - notifyReply(replyTo, doneEvt) -} - -// notifyReply delivers the terminal event to the submission's direct-reply -// channel when one is configured. Non-blocking: a slow direct consumer must -// not stall the engine loop. -func notifyReply(replyTo chan<- *Event, evt *Event) { - if replyTo == nil { - return - } - select { - case replyTo <- evt: - default: - } -} - -func toAsyncEvent(evt engine.StreamEvent) *Event { - e := &Event{ - ID: uuid.NewString(), - Timestamp: time.Now(), - SourceType: evt.Type, - } - switch evt.Type { - case "content": - e.Type = EventToken - e.Content = evt.Content - case "tool_use": - e.Type = EventToolCall - e.ToolName = evt.ToolName - e.ToolID = evt.ToolID - case "tool_result": - e.Type = EventToolResult - e.ToolName = evt.ToolName - e.Content = evt.Content - case "error": - e.Type = EventError - e.Content = evt.Content - case "usage": - e.Type = EventUsage - if evt.Usage != nil { - e.Usage = &Usage{ - PromptTokens: evt.Usage.PromptTokens, - CompletionTokens: evt.Usage.CompletionTokens, - CacheReadTokens: evt.Usage.CacheReadTokens, - CacheWriteTokens: evt.Usage.CacheWriteTokens, - TotalTokens: evt.Usage.PromptTokens + evt.Usage.CompletionTokens, - Provider: evt.Usage.Provider, - Model: evt.Usage.Model, - } - } - case "thinking": - e.Type = EventThinking - e.Content = evt.Content - case "done": - e.Type = EventDone - default: - // Engine stream events without a mapping (compact_start, - // blast_radius, ...) must not become garbage zero-value events: - // preserve them as EventInfo with the raw type for diagnostics. - e.Type = EventInfo - e.Content = evt.Content - } - return e -} diff --git a/internal/engine/async/engine_test.go b/internal/engine/async/engine_test.go deleted file mode 100644 index 909eca0a3..000000000 --- a/internal/engine/async/engine_test.go +++ /dev/null @@ -1,357 +0,0 @@ -package async - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/GrayCodeAI/hawk/internal/types" -) - -// fakeClient is a scripted ChatClient for exercising the engine loop. -type fakeClient struct { - mu sync.Mutex - script []types.EyrieStreamEvent - streamStarted chan struct{} - streamCtx context.Context -} - -func newFakeClient(events ...types.EyrieStreamEvent) *fakeClient { - return &fakeClient{ - script: events, - streamStarted: make(chan struct{}), - } -} - -func (f *fakeClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - return &types.EyrieResponse{Content: "mock", FinishReason: "end_turn"}, nil -} - -func (f *fakeClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) { - f.mu.Lock() - f.streamCtx = ctx - f.mu.Unlock() - select { - case <-f.streamStarted: - default: - close(f.streamStarted) - } - - ch := make(chan types.EyrieStreamEvent, 16) - go func() { - defer close(ch) - for _, evt := range f.script { - select { - case ch <- evt: - case <-ctx.Done(): - return - } - } - }() - return &types.StreamResult{Events: ch}, nil -} - -// cancelTurns is a client whose FIRST stream blocks until the context is -// canceled (emitting one content event first); subsequent streams complete -// normally with a done event. -type cancelTurns struct { - streamStarted chan struct{} - mu sync.Mutex - call int -} - -func (c *cancelTurns) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - return &types.EyrieResponse{Content: "mock", FinishReason: "end_turn"}, nil -} - -func (c *cancelTurns) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) { - select { - case <-c.streamStarted: - default: - close(c.streamStarted) - } - c.mu.Lock() - c.call++ - first := c.call == 1 - c.mu.Unlock() - - ch := make(chan types.EyrieStreamEvent, 4) - ch <- types.EyrieStreamEvent{Type: "content", Content: "thinking..."} - if first { - go func() { - <-ctx.Done() - close(ch) - }() - } else { - close(ch) - } - return &types.StreamResult{Events: ch}, nil -} - -func newTestSession(t *testing.T, client engine.ChatClient) *engine.Session { - t.Helper() - sess := engine.NewSession("test-provider", "test-model", "system", tool.NewRegistry()) - sess.SetTestClient(client) - return sess -} - -// drainEvents reads events until the terminal EventDone (or timeout). -func drainEvents(t *testing.T, ch <-chan *Event) []*Event { - t.Helper() - var out []*Event - timeout := time.After(10 * time.Second) - for { - select { - case evt := <-ch: - out = append(out, evt) - if evt.Type == EventDone { - return out - } - case <-timeout: - t.Fatalf("timed out waiting for EventDone; got %d events", len(out)) - } - } -} - -func TestEngineTurnEmitsTerminalDone(t *testing.T) { - client := newFakeClient( - types.EyrieStreamEvent{Type: "content", Content: "hello"}, - types.EyrieStreamEvent{Type: "usage", Usage: &types.EyrieUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}, - types.EyrieStreamEvent{Type: "done", StopReason: "end_turn"}, - ) - e := New(newTestSession(t, client)) - e.Start(context.Background()) - defer e.Stop() - - sub := e.Submit("hi") - evts := drainEvents(t, e.Events()) - - // The session consumes the client's "done" and emits its own; the engine - // then appends a terminal EventDone: content, usage, done. - if got, want := len(evts), 3; got != want { - t.Fatalf("expected %d events (content, usage, done), got %d: %+v", want, got, evts) - } - if evts[0].Type != EventToken || evts[0].Content != "hello" { - t.Errorf("first event = %+v, want token event", evts[0]) - } - if evts[1].Type != EventUsage || evts[1].Usage == nil { - t.Errorf("second event = %+v, want usage event", evts[1]) - } else if evts[1].Usage.TotalTokens != 15 { - t.Errorf("usage = %+v, want total 15", evts[1].Usage) - } - if evts[len(evts)-1].Type != EventDone { - t.Errorf("last event = %+v, want EventDone", evts[len(evts)-1]) - } - if sub.ID == "" { - t.Error("submission ID must be set") - } -} - -func TestEngineStopThenStartProcessesAgain(t *testing.T) { - client := newFakeClient(types.EyrieStreamEvent{Type: "done", StopReason: "end_turn"}) - e := New(newTestSession(t, client)) - - e.Start(context.Background()) - e.Submit("first") - drainEvents(t, e.Events()) - e.Stop() - - // After Stop, no new events are processed. - ch := e.Events() - e.Submit("stale") - select { - case evt := <-ch: - t.Fatalf("got event %+v after Stop", evt) - case <-time.After(300 * time.Millisecond): - } - - // Restart must spawn a fresh loop and process new submissions. - e.Start(context.Background()) - defer e.Stop() - e.Submit("second") - evts := drainEvents(t, e.Events()) - if evts[len(evts)-1].Type != EventDone { - t.Errorf("restarted engine did not process submission") - } -} - -func TestEngineCancelAbortsTurnAndKeepsRunning(t *testing.T) { - c := &cancelTurns{streamStarted: make(chan struct{})} - e := New(newTestSession(t, c)) - e.Start(context.Background()) - defer e.Stop() - - ch := e.Events() - e.Submit("long turn") - - // Wait until the turn is streaming, then cancel it. - select { - case <-c.streamStarted: - case <-time.After(10 * time.Second): - t.Fatal("stream never started") - } - e.Cancel() - - // The canceled turn must still terminate with EventDone. - first := drainEvents(t, ch) - if first[len(first)-1].Type != EventDone { - t.Errorf("canceled turn did not emit EventDone") - } - - // The engine must still process the next turn. - e.Submit("after cancel") - second := drainEvents(t, ch) - if second[len(second)-1].Type != EventDone { - t.Errorf("turn after cancel did not complete") - } -} - -func TestEngineStreamErrorEmitsErrorThenDone(t *testing.T) { - client := newFakeClient( - types.EyrieStreamEvent{Type: "error", Error: "provider exploded"}, - ) - e := New(newTestSession(t, client)) - e.Start(context.Background()) - defer e.Stop() - - e.Submit("hi") - evts := drainEvents(t, e.Events()) - - var sawErr, sawDone bool - for _, evt := range evts { - switch evt.Type { - case EventError: - sawErr = true - case EventDone: - sawDone = true - } - } - if !sawErr { - t.Errorf("expected EventError, got %+v", evts) - } - if !sawDone { - t.Errorf("expected terminal EventDone after stream error, got %+v", evts) - } -} - -func TestEngineReplyToReceivesTerminalEvent(t *testing.T) { - client := newFakeClient(types.EyrieStreamEvent{Type: "done", StopReason: "end_turn"}) - e := New(newTestSession(t, client)) - e.Start(context.Background()) - defer e.Stop() - - reply := make(chan *Event, 1) - e.subQ.Submit(&Submission{ - ID: "r1", - Op: OpUserTurn, - Payload: "direct", - Timestamp: time.Now(), - ReplyTo: reply, - }) - - select { - case evt := <-reply: - if evt.Type != EventDone { - t.Errorf("reply = %+v, want EventDone", evt) - } - case <-time.After(10 * time.Second): - t.Fatal("timed out waiting for direct reply") - } -} - -func TestToAsyncEventMapsAllEngineTypes(t *testing.T) { - usage := &engine.StreamUsage{ - PromptTokens: 10, - CompletionTokens: 5, - CacheReadTokens: 3, - CacheWriteTokens: 2, - Provider: "p", - Model: "m", - } - - cases := []struct { - in engine.StreamEvent - typ EventType - }{ - {engine.StreamEvent{Type: "content", Content: "x"}, EventToken}, - {engine.StreamEvent{Type: "tool_use", ToolName: "read", ToolID: "t1"}, EventToolCall}, - {engine.StreamEvent{Type: "tool_result", ToolName: "read", Content: "out"}, EventToolResult}, - {engine.StreamEvent{Type: "error", Content: "boom"}, EventError}, - {engine.StreamEvent{Type: "usage", Usage: usage}, EventUsage}, - {engine.StreamEvent{Type: "thinking", Content: "hmm"}, EventThinking}, - {engine.StreamEvent{Type: "done"}, EventDone}, - {engine.StreamEvent{Type: "compact_start", Content: "compacting"}, EventInfo}, - } - - for _, tc := range cases { - got := toAsyncEvent(tc.in) - if got.Type != tc.typ { - t.Errorf("toAsyncEvent(%q).Type = %q, want %q", tc.in.Type, got.Type, tc.typ) - } - if got.ID == "" { - t.Errorf("toAsyncEvent(%q) has empty ID", tc.in.Type) - } - if tc.in.Type == "compact_start" && got.SourceType != "compact_start" { - t.Errorf("unmapped event must preserve SourceType, got %q", got.SourceType) - } - } -} - -func TestEventQueueUnsubscribe(t *testing.T) { - eq := NewEventQueue(100) - ch := eq.Subscribe() - eq.Unsubscribe(ch) - - eq.Push(&Event{ID: "e1", Type: EventDone}) - select { - case evt := <-ch: - t.Fatalf("unsubscribed channel received %+v", evt) - default: - } - // Unsubscribing a channel that is already gone must be a no-op. - eq.Unsubscribe(ch) -} - -func TestEventQueueReplayIsBounded(t *testing.T) { - eq := NewEventQueue(3) - for i := 0; i < 10; i++ { - eq.Push(&Event{ID: string(rune('a' + i))}) - } - got := eq.Replay() - if len(got) != 3 { - t.Fatalf("replay length = %d, want 3 (bounded)", len(got)) - } - if got[0].ID != "h" { - t.Errorf("oldest retained event = %q, want newest events only", got[0].ID) - } -} - -func TestSubmissionQueueDrainsAllAfterSingleNotify(t *testing.T) { - sq := NewSubmissionQueue() - for i := 0; i < 5; i++ { - sq.Submit(&Submission{ID: string(rune('a' + i))}) - } - if sq.Len() != 5 { - t.Fatalf("Len = %d, want 5", sq.Len()) - } - var got []string - for { - s, ok := sq.pop() - if !ok { - break - } - got = append(got, s.ID) - } - if len(got) != 5 { - t.Fatalf("popped %d, want 5", len(got)) - } - // The leftover notify token must not poison the next submit/pop cycle. - sq.Submit(&Submission{ID: "zz"}) - s, ok := sq.pop() - if !ok || s.ID != "zz" { - t.Errorf("pop after second submit = %+v, %v; want zz", s, ok) - } -} diff --git a/internal/engine/async/event.go b/internal/engine/async/event.go deleted file mode 100644 index 12d4041ff..000000000 --- a/internal/engine/async/event.go +++ /dev/null @@ -1,183 +0,0 @@ -package async - -import ( - "sync" - "time" -) - -// OpType identifies the kind of operation submitted to the engine. -type OpType string - -const ( - OpUserTurn OpType = "user_turn" - OpCancel OpType = "cancel" - OpResume OpType = "resume" - OpInterrupt OpType = "interrupt" -) - -// Submission represents a user request sent to the async engine. -type Submission struct { - ID string - Op OpType - Payload string - Timestamp time.Time - ReplyTo chan<- *Event // optional: caller receives the terminal event (EventDone/EventError) directly -} - -// EventType identifies the kind of event emitted by the engine. -type EventType string - -const ( - EventToken EventType = "token" - EventToolCall EventType = "tool_call" - EventToolResult EventType = "tool_result" - EventDone EventType = "done" - EventError EventType = "error" - EventThinking EventType = "thinking" - EventUsage EventType = "usage" - EventInfo EventType = "info" -) - -// Event is a single event from the async engine. -type Event struct { - ID string - Type EventType - Content string - ToolName string - ToolID string - Usage *Usage - Timestamp time.Time - SessionID string - // SourceType preserves the raw engine stream event type for unmapped - // events (e.g. "compact_start") so diagnostics stay accurate. - SourceType string -} - -// Usage tracks token usage. -type Usage struct { - PromptTokens int - CompletionTokens int - CacheReadTokens int - CacheWriteTokens int - TotalTokens int - Provider string - Model string -} - -// EventQueue is a thread-safe, replayable queue of events. -type EventQueue struct { - mu sync.RWMutex - events []*Event - subs []chan *Event - subMu sync.Mutex - maxSize int -} - -// NewEventQueue creates an event queue with the given max capacity. -func NewEventQueue(maxSize int) *EventQueue { - if maxSize <= 0 { - maxSize = 10000 - } - return &EventQueue{ - events: make([]*Event, 0, maxSize), - maxSize: maxSize, - } -} - -// Push adds an event and broadcasts it to all subscribers. -func (eq *EventQueue) Push(evt *Event) { - eq.mu.Lock() - eq.events = append(eq.events, evt) - if len(eq.events) > eq.maxSize { - eq.events = eq.events[len(eq.events)-eq.maxSize:] - } - eq.mu.Unlock() - - eq.subMu.Lock() - for _, sub := range eq.subs { - select { - case sub <- evt: - default: - // Drop if subscriber is slow - } - } - eq.subMu.Unlock() -} - -// Subscribe returns a channel that receives new events. -func (eq *EventQueue) Subscribe() <-chan *Event { - ch := make(chan *Event, 256) - eq.subMu.Lock() - eq.subs = append(eq.subs, ch) - eq.subMu.Unlock() - return ch -} - -// Unsubscribe removes a subscriber channel. It is safe to call from the -// consumer goroutine; a channel that is not subscribed is a no-op. -func (eq *EventQueue) Unsubscribe(ch <-chan *Event) { - eq.subMu.Lock() - defer eq.subMu.Unlock() - for i, sub := range eq.subs { - if sub == ch { - eq.subs = append(eq.subs[:i], eq.subs[i+1:]...) - return - } - } -} - -// Replay returns all stored events for replay. -func (eq *EventQueue) Replay() []*Event { - eq.mu.RLock() - defer eq.mu.RUnlock() - out := make([]*Event, len(eq.events)) - copy(out, eq.events) - return out -} - -// SubmissionQueue is a thread-safe queue for user submissions. -type SubmissionQueue struct { - mu sync.Mutex - submissions []*Submission - notify chan struct{} -} - -// NewSubmissionQueue creates a new submission queue. -func NewSubmissionQueue() *SubmissionQueue { - return &SubmissionQueue{ - submissions: make([]*Submission, 0, 64), - notify: make(chan struct{}, 1), - } -} - -// Submit adds a submission and notifies the engine. -func (sq *SubmissionQueue) Submit(s *Submission) { - sq.mu.Lock() - sq.submissions = append(sq.submissions, s) - sq.mu.Unlock() - - select { - case sq.notify <- struct{}{}: - default: - } -} - -// pop returns the oldest pending submission without blocking. Returns ok=false -// when the queue is empty. The engine drains the queue after each notify. -func (sq *SubmissionQueue) pop() (s *Submission, ok bool) { - sq.mu.Lock() - defer sq.mu.Unlock() - if len(sq.submissions) == 0 { - return nil, false - } - s = sq.submissions[0] - sq.submissions = sq.submissions[1:] - return s, true -} - -// Len returns the number of pending submissions. -func (sq *SubmissionQueue) Len() int { - sq.mu.Lock() - defer sq.mu.Unlock() - return len(sq.submissions) -} diff --git a/internal/engine/code/aliases.go b/internal/engine/code/aliases.go deleted file mode 100644 index 38ab7934c..000000000 --- a/internal/engine/code/aliases.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package code provides code-aware features: context extraction, -// lenses, actions, and explainer. See ../../docs/plans/engine-refactor-plan.md. -package code diff --git a/internal/engine/code/code_actions.go b/internal/engine/code/code_actions.go deleted file mode 100644 index b39ff6803..000000000 --- a/internal/engine/code/code_actions.go +++ /dev/null @@ -1,605 +0,0 @@ -package code - -import ( - "fmt" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "text/template" - - "github.com/GrayCodeAI/hawk/internal/ui/icons" -) - -type CodeAction struct { - ID string - Title string - Description string - File string - Line int - Category string - Priority int - Fix string - Confidence float64 -} - -type ActionDetector struct { - Rules []ActionRule - mu sync.RWMutex -} - -type ActionRule struct { - ID string - Name string - Category string - Language string - Pattern *regexp.Regexp - Antipattern *regexp.Regexp - Priority int - Message string - FixTemplate string -} - -func NewActionDetector() *ActionDetector { - ad := &ActionDetector{ - Rules: builtinRules(), - } - return ad -} - -func builtinRules() []ActionRule { - return []ActionRule{ - { - ID: "go-err-wrap", - Name: "Wrap error with context", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`if err != nil \{\s*return err\s*\}`), - Antipattern: regexp.MustCompile(`fmt\.Errorf\(.+%w`), - Priority: 3, - Message: "Wrap error with context using fmt.Errorf", - FixTemplate: `return fmt.Errorf("{{.FuncName}}: %w", err)`, - }, - { - ID: "go-interface-any", - Name: "Use any instead of interface{}", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`interface\{\}`), - Priority: 5, - Message: "Use 'any' instead of 'interface{}' (Go 1.18+)", - FixTemplate: "any", - }, - { - ID: "go-ioutil-readfile", - Name: "Replace deprecated ioutil.ReadFile", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`ioutil\.ReadFile`), - Priority: 3, - Message: "ioutil.ReadFile is deprecated; use os.ReadFile", - FixTemplate: "os.ReadFile", - }, - { - ID: "go-ioutil-readall", - Name: "Replace deprecated ioutil.ReadAll", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`ioutil\.ReadAll`), - Priority: 3, - Message: "ioutil.ReadAll is deprecated; use io.ReadAll", - FixTemplate: "io.ReadAll", - }, - { - ID: "go-ioutil-writefile", - Name: "Replace deprecated ioutil.WriteFile", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`ioutil\.WriteFile`), - Priority: 3, - Message: "ioutil.WriteFile is deprecated; use os.WriteFile", - FixTemplate: "os.WriteFile", - }, - { - ID: "go-naked-return", - Name: "Avoid naked returns in long functions", - Category: "refactor", - Language: "go", - Pattern: regexp.MustCompile(`(?m)^\s*return\s*$`), - Priority: 5, - Message: "Naked return in function; consider using named returns explicitly", - }, - { - ID: "go-append-loop", - Name: "Pre-allocate slice", - Category: "performance", - Language: "go", - Pattern: regexp.MustCompile(`for .+ range .+\{[^}]*append\(`), - Antipattern: regexp.MustCompile(`make\(\[\]`), - Priority: 3, - Message: "Append in loop without pre-allocation; consider make([]T, 0, n)", - FixTemplate: "make([]T, 0, len(items))", - }, - { - ID: "go-string-concat-loop", - Name: "Use strings.Builder for concatenation", - Category: "performance", - Language: "go", - Pattern: regexp.MustCompile(`for .+\{[^}]*\+=\s*".+"`), - Priority: 3, - Message: "String concatenation in loop; use strings.Builder", - FixTemplate: "var sb strings.Builder", - }, - { - ID: "go-regexp-in-func", - Name: "Move regexp.MustCompile to package level", - Category: "performance", - Language: "go", - Pattern: regexp.MustCompile(`func .+\{[^}]*regexp\.MustCompile`), - Priority: 1, - Message: "regexp.MustCompile inside function; move to package-level var", - }, - { - ID: "go-map-no-ok", - Name: "Use comma-ok pattern for map access", - Category: "performance", - Language: "go", - Pattern: regexp.MustCompile(`[a-zA-Z_]\w*\[[^\]]+\]\s*[^,=]`), - Antipattern: regexp.MustCompile(`,\s*ok\s*:?=`), - Priority: 5, - Message: "Map access without ok check; consider comma-ok pattern", - }, - { - ID: "py-bare-except", - Name: "Avoid bare except", - Category: "fix", - Language: "python", - Pattern: regexp.MustCompile(`(?m)^\s*except\s*:`), - Antipattern: regexp.MustCompile(`except\s+\w`), - Priority: 1, - Message: "Bare except catches all exceptions including SystemExit; use 'except Exception:'", - FixTemplate: "except Exception:", - }, - { - ID: "py-type-check", - Name: "Use isinstance for type checking", - Category: "refactor", - Language: "python", - Pattern: regexp.MustCompile(`type\(\w+\)\s*==\s*\w+`), - Priority: 3, - Message: "Use isinstance(x, T) instead of type(x) == T", - FixTemplate: "isinstance(x, T)", - }, - { - ID: "py-mutable-default", - Name: "Avoid mutable default argument", - Category: "fix", - Language: "python", - Pattern: regexp.MustCompile(`def \w+\([^)]*=\s*(\[\]|\{\})`), - Priority: 1, - Message: "Mutable default argument; use None and set inside function body", - FixTemplate: "=None", - }, - { - ID: "py-open-no-with", - Name: "Use context manager for file operations", - Category: "fix", - Language: "python", - Pattern: regexp.MustCompile(`(?m)^\s*\w+\s*=\s*open\(`), - Antipattern: regexp.MustCompile(`with\s+open\(`), - Priority: 1, - Message: "open() without 'with' statement; use context manager", - FixTemplate: "with open(...) as f:", - }, - { - ID: "py-string-format-percent", - Name: "Use f-string or .format()", - Category: "style", - Language: "python", - Pattern: regexp.MustCompile(`"[^"]*%[sd]" %`), - Priority: 5, - Message: "Use f-string or .format() instead of % formatting", - }, - { - ID: "ts-any-type", - Name: "Avoid any type", - Category: "refactor", - Language: "typescript", - Pattern: regexp.MustCompile(`:\s*any\b`), - Priority: 3, - Message: "Avoid 'any' type; use a proper type or 'unknown'", - FixTemplate: ": unknown", - }, - { - ID: "ts-loose-equality-null", - Name: "Use strict equality for null checks", - Category: "fix", - Language: "typescript", - Pattern: regexp.MustCompile(`==\s*null`), - Antipattern: regexp.MustCompile(`===\s*null`), - Priority: 3, - Message: "Use === null or optional chaining instead of == null", - FixTemplate: "=== null", - }, - { - ID: "ts-console-log", - Name: "Remove console.log in production", - Category: "style", - Language: "typescript", - Pattern: regexp.MustCompile(`console\.log\(`), - Priority: 5, - Message: "console.log in production code; use a logger or remove", - }, - { - ID: "ts-callback-hell", - Name: "Refactor nested callbacks", - Category: "refactor", - Language: "typescript", - Pattern: regexp.MustCompile(`\.\s*then\([^)]*\.\s*then\(`), - Priority: 3, - Message: "Nested .then() callbacks; consider async/await", - }, - { - ID: "ts-non-null-assertion", - Name: "Avoid non-null assertion", - Category: "fix", - Language: "typescript", - Pattern: regexp.MustCompile(`\w+!\.\w+`), - Priority: 3, - Message: "Non-null assertion operator; add proper null check", - }, - { - ID: "todo-comment", - Name: "Address TODO/FIXME/HACK comment", - Category: "refactor", - Language: "", - Pattern: regexp.MustCompile(`(?i)(TODO|FIXME|HACK|XXX)\b`), - Priority: 5, - Message: "TODO/FIXME/HACK comment found; consider addressing it", - }, - { - ID: "magic-number", - Name: "Extract magic number to constant", - Category: "style", - Language: "", - Pattern: regexp.MustCompile(`(?m)(?:==|!=|<=|>=|<|>)\s*\d{3,}`), - Priority: 5, - Message: "Magic number detected; extract to a named constant", - }, - { - ID: "long-function", - Name: "Function is too long", - Category: "refactor", - Language: "", - Pattern: regexp.MustCompile(`(?s)(func |def |function )\w+[^{]*\{[^\}]{999,}\}`), - Priority: 3, - Message: "Function exceeds 50 lines; consider splitting into smaller functions", - }, - { - ID: "deep-nesting", - Name: "Reduce nesting depth", - Category: "refactor", - Language: "", - Pattern: regexp.MustCompile(`(?m)^(\t{5,}|\s{20,})\S`), - Priority: 3, - Message: "Deep nesting (>4 levels); consider early returns or extracting functions", - }, - { - ID: "hardcoded-credential", - Name: "Possible hardcoded credential", - Category: "security", - Language: "", - Pattern: regexp.MustCompile(`(?i)(password|secret|api_key|token)\s*[:=]\s*["'][^"']+["']`), - Priority: 1, - Message: "Possible hardcoded credential; use environment variables or secret manager", - }, - { - ID: "sql-injection", - Name: "Possible SQL injection", - Category: "security", - Language: "", - Pattern: regexp.MustCompile(`(?i)(SELECT|INSERT|UPDATE|DELETE)\s+.+\+\s*\w+`), - Priority: 1, - Message: "String concatenation in SQL query; use parameterized queries", - }, - } -} - -func (ad *ActionDetector) Detect(path, content string) []CodeAction { - ad.mu.RLock() - defer ad.mu.RUnlock() - - lang := detectLanguageFromPath(path) - lines := strings.Split(content, "\n") - var actions []CodeAction - - for _, rule := range ad.Rules { - if rule.Language != "" && rule.Language != lang { - continue - } - - if isMultilinePattern(rule) { - matches := rule.Pattern.FindAllStringIndex(content, -1) - for _, m := range matches { - if rule.Antipattern != nil { - start := m[0] - if start > 200 { - start = m[0] - 200 - } else { - start = 0 - } - end := m[1] + 200 - if end > len(content) { - end = len(content) - } - ctx := content[start:end] - if rule.Antipattern.MatchString(ctx) { - continue - } - } - - line := countNewlines(content[:m[0]]) + 1 - fix := generateFix(rule, content[m[0]:m[1]]) - actions = append(actions, CodeAction{ - ID: rule.ID, - Title: rule.Name, - Description: rule.Message, - File: path, - Line: line, - Category: rule.Category, - Priority: rule.Priority, - Fix: fix, - Confidence: computeConfidence(rule), - }) - } - continue - } - - for i, line := range lines { - if !rule.Pattern.MatchString(line) { - continue - } - if rule.Antipattern != nil && rule.Antipattern.MatchString(line) { - continue - } - - fix := generateFix(rule, rule.Pattern.FindString(line)) - actions = append(actions, CodeAction{ - ID: rule.ID, - Title: rule.Name, - Description: rule.Message, - File: path, - Line: i + 1, - Category: rule.Category, - Priority: rule.Priority, - Fix: fix, - Confidence: computeConfidence(rule), - }) - } - } - - sort.Slice(actions, func(i, j int) bool { - if actions[i].Priority != actions[j].Priority { - return actions[i].Priority < actions[j].Priority - } - return actions[i].Line < actions[j].Line - }) - - return actions -} - -func (ad *ActionDetector) DetectForDiff(diff string) []CodeAction { - added := extractAddedLines(diff) - if added.path == "" { - return nil - } - return ad.Detect(added.path, added.content) -} - -func FormatSuggestions(actions []CodeAction, maxDisplay int) string { - if len(actions) == 0 { - return "" - } - - var sb strings.Builder - file := actions[0].File - sb.WriteString(fmt.Sprintf("Suggestions for %s:\n", file)) - - count := len(actions) - if maxDisplay > 0 && count > maxDisplay { - count = maxDisplay - } - - for i := 0; i < count; i++ { - a := actions[i] - icon := categoryIcon(a.Category) - sb.WriteString(fmt.Sprintf("%s [%s] L%d: %s\n", icon, a.Category, a.Line, a.Title)) - if a.Fix != "" { - sb.WriteString(fmt.Sprintf(" - %s\n", a.Fix)) - } - } - - if len(actions) > count { - sb.WriteString(fmt.Sprintf(" ... and %d more suggestions\n", len(actions)-count)) - } - - return sb.String() -} - -func ApplyFix(action CodeAction, content string) (string, error) { - if action.Fix == "" { - return "", fmt.Errorf("no fix available for action %s", action.ID) - } - if action.Line <= 0 { - return "", fmt.Errorf("invalid line number %d", action.Line) - } - - lines := strings.Split(content, "\n") - if action.Line > len(lines) { - return "", fmt.Errorf("line %d exceeds file length %d", action.Line, len(lines)) - } - - idx := action.Line - 1 - original := lines[idx] - - detector := NewActionDetector() - var rule *ActionRule - for i := range detector.Rules { - if detector.Rules[i].ID == action.ID { - rule = &detector.Rules[i] - break - } - } - - if rule != nil && rule.Pattern != nil { - replaced := rule.Pattern.ReplaceAllString(original, action.Fix) - if replaced != original { - lines[idx] = replaced - return strings.Join(lines, "\n"), nil - } - } - - indent := extractIndent(original) - lines[idx] = indent + strings.TrimSpace(action.Fix) - return strings.Join(lines, "\n"), nil -} - -func detectLanguageFromPath(path string) string { - lower := strings.ToLower(path) - switch { - case strings.HasSuffix(lower, ".go"): - return "go" - case strings.HasSuffix(lower, ".py"): - return "python" - case strings.HasSuffix(lower, ".ts"), strings.HasSuffix(lower, ".tsx"): - return "typescript" - case strings.HasSuffix(lower, ".js"), strings.HasSuffix(lower, ".jsx"): - return "typescript" - case strings.HasSuffix(lower, ".rs"): - return "rust" - case strings.HasSuffix(lower, ".rb"): - return "ruby" - default: - return "" - } -} - -func isMultilinePattern(rule ActionRule) bool { - p := rule.Pattern.String() - return strings.Contains(p, "(?s)") || strings.Contains(p, `\{[^}]*`) || strings.Contains(p, `\{[^\}]`) -} - -func countNewlines(s string) int { - return strings.Count(s, "\n") -} - -func generateFix(rule ActionRule, matched string) string { - if rule.FixTemplate == "" { - return "" - } - - if !strings.Contains(rule.FixTemplate, "{{") { - return rule.FixTemplate - } - - tmpl, err := template.New("fix").Parse(rule.FixTemplate) - if err != nil { - return rule.FixTemplate - } - - data := map[string]string{ - "Matched": matched, - "FuncName": "operation", - } - - var sb strings.Builder - if err := tmpl.Execute(&sb, data); err != nil { - return rule.FixTemplate - } - return sb.String() -} - -func computeConfidence(rule ActionRule) float64 { - switch rule.Priority { - case 1: - return 0.9 - case 2: - return 0.8 - case 3: - return 0.7 - case 4: - return 0.6 - default: - return 0.5 - } -} - -func categoryIcon(category string) string { - switch category { - case "refactor": - return "FIX:" - case "performance": - return icons.Bolt() - case "security": - return "LOCK:" - case "style": - return "STYLE:" - case "fix": - return "BUG:" - default: - return icons.Brain() - } -} - -func extractIndent(line string) string { - for i, ch := range line { - if ch != ' ' && ch != '\t' { - return line[:i] - } - } - return line -} - -type diffLines struct { - path string - content string -} - -func extractAddedLines(diff string) diffLines { - lines := strings.Split(diff, "\n") - var path string - var added []string - var currentLine int - - for _, line := range lines { - if strings.HasPrefix(line, "+++ b/") { - path = strings.TrimPrefix(line, "+++ b/") - continue - } - if strings.HasPrefix(line, "@@") { - parts := strings.Split(line, "+") - if len(parts) >= 2 { - numStr := strings.Split(parts[1], ",")[0] - n, err := strconv.Atoi(numStr) - if err == nil { - currentLine = n - } - } - continue - } - if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { - added = append(added, strings.TrimPrefix(line, "+")) - currentLine++ - } else if !strings.HasPrefix(line, "-") { - currentLine++ - } - } - - _ = currentLine - - return diffLines{ - path: path, - content: strings.Join(added, "\n"), - } -} diff --git a/internal/engine/code/code_actions_test.go b/internal/engine/code/code_actions_test.go deleted file mode 100644 index a82806b38..000000000 --- a/internal/engine/code/code_actions_test.go +++ /dev/null @@ -1,550 +0,0 @@ -package code - -import ( - "strings" - "testing" -) - -func TestDetect_GoErrorWrapping(t *testing.T) { - detector := NewActionDetector() - content := `package main - -func handle() error { - err := doSomething() - if err != nil { return err } - return nil -} -` - actions := detector.Detect("main.go", content) - - found := false - for _, a := range actions { - if a.ID == "go-err-wrap" { - found = true - if a.Category != "refactor" { - t.Errorf("expected category 'refactor', got %q", a.Category) - } - if a.Fix == "" { - t.Error("expected a fix suggestion for error wrapping") - } - if !strings.Contains(a.Fix, "%w") { - t.Errorf("expected fix to contain %%w, got %q", a.Fix) - } - break - } - } - if !found { - t.Error("expected go-err-wrap action to be detected") - } -} - -func TestDetect_GoDeprecatedAPI(t *testing.T) { - detector := NewActionDetector() - content := `package main - -import "io/ioutil" - -func read() { - data, _ := ioutil.ReadFile("test.txt") - _ = data -} -` - actions := detector.Detect("main.go", content) - - found := false - for _, a := range actions { - if a.ID == "go-ioutil-readfile" { - found = true - if !strings.Contains(a.Fix, "os.ReadFile") { - t.Errorf("expected fix to suggest os.ReadFile, got %q", a.Fix) - } - if a.Priority != 3 { - t.Errorf("expected priority 3, got %d", a.Priority) - } - break - } - } - if !found { - t.Error("expected go-ioutil-readfile action to be detected") - } -} - -func TestDetect_GoPerformancePatterns(t *testing.T) { - detector := NewActionDetector() - - t.Run("string concatenation in loop", func(t *testing.T) { - content := `package main - -func build() string { - result := "" - for i := 0; i < 100; i++ { - result += "item" - } - return result -} -` - actions := detector.Detect("main.go", content) - found := false - for _, a := range actions { - if a.ID == "go-string-concat-loop" { - found = true - if a.Category != "performance" { - t.Errorf("expected category 'performance', got %q", a.Category) - } - break - } - } - if !found { - t.Error("expected go-string-concat-loop action to be detected") - } - }) - - t.Run("interface{} usage", func(t *testing.T) { - content := `package main - -func process(data interface{}) { - _ = data -} -` - actions := detector.Detect("main.go", content) - found := false - for _, a := range actions { - if a.ID == "go-interface-any" { - found = true - if !strings.Contains(a.Fix, "any") { - t.Errorf("expected fix to suggest 'any', got %q", a.Fix) - } - break - } - } - if !found { - t.Error("expected go-interface-any action to be detected") - } - }) -} - -func TestDetect_PythonBareExcept(t *testing.T) { - detector := NewActionDetector() - content := `def risky(): - try: - do_stuff() - except: - pass -` - actions := detector.Detect("handler.py", content) - - found := false - for _, a := range actions { - if a.ID == "py-bare-except" { - found = true - if a.Category != "fix" { - t.Errorf("expected category 'fix', got %q", a.Category) - } - if a.Priority != 1 { - t.Errorf("expected priority 1, got %d", a.Priority) - } - if !strings.Contains(a.Fix, "except Exception:") { - t.Errorf("expected fix to suggest 'except Exception:', got %q", a.Fix) - } - break - } - } - if !found { - t.Error("expected py-bare-except action to be detected") - } -} - -func TestDetect_TypeScriptAnyType(t *testing.T) { - detector := NewActionDetector() - content := `function handle(req: any, res: any): void { - const data: any = req.body; - res.json(data); -} -` - actions := detector.Detect("handler.ts", content) - - count := 0 - for _, a := range actions { - if a.ID == "ts-any-type" { - count++ - if a.Category != "refactor" { - t.Errorf("expected category 'refactor', got %q", a.Category) - } - } - } - if count == 0 { - t.Error("expected ts-any-type actions to be detected") - } - if count < 2 { - t.Errorf("expected at least 2 ts-any-type detections, got %d", count) - } -} - -func TestDetect_UniversalTODO(t *testing.T) { - detector := NewActionDetector() - content := `package main - -// TODO: fix this later -func broken() { - // FIXME: handle edge case - // HACK: workaround for now -} -` - actions := detector.Detect("main.go", content) - - count := 0 - for _, a := range actions { - if a.ID == "todo-comment" { - count++ - if a.Priority != 5 { - t.Errorf("expected priority 5, got %d", a.Priority) - } - } - } - if count < 3 { - t.Errorf("expected at least 3 TODO/FIXME/HACK detections, got %d", count) - } -} - -func TestFormatSuggestions(t *testing.T) { - actions := []CodeAction{ - { - ID: "go-err-wrap", - Title: "Wrap error with context", - Category: "refactor", - File: "src/handler.go", - Line: 15, - Priority: 3, - Fix: `return fmt.Errorf("handle request: %w", err)`, - }, - { - ID: "go-append-loop", - Title: "Pre-allocate slice", - Category: "performance", - File: "src/handler.go", - Line: 42, - Priority: 3, - Fix: "make([]User, 0, len(input))", - }, - { - ID: "hardcoded-credential", - Title: "Possible hardcoded credential", - Category: "security", - File: "src/handler.go", - Line: 67, - Priority: 1, - }, - } - - output := FormatSuggestions(actions, 5) - - if !strings.Contains(output, "Suggestions for src/handler.go:") { - t.Error("expected header with file path") - } - if !strings.Contains(output, "[refactor]") { - t.Error("expected [refactor] category tag") - } - if !strings.Contains(output, "[performance]") { - t.Error("expected [performance] category tag") - } - if !strings.Contains(output, "[security]") { - t.Error("expected [security] category tag") - } - if !strings.Contains(output, "L15") { - t.Error("expected line number L15") - } - if !strings.Contains(output, "L42") { - t.Error("expected line number L42") - } - if !strings.Contains(output, `fmt.Errorf`) { - t.Error("expected fix text in output") - } - // security action has no fix so should not have " - " line - lines := strings.Split(output, "\n") - for i, line := range lines { - if strings.Contains(line, "Possible hardcoded credential") { - // Next line should not be a fix line for this action - if i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), "- ") { - // Check it's not an empty fix - fixLine := strings.TrimSpace(lines[i+1]) - if fixLine == "- " || fixLine == "-" { - t.Error("should not display empty fix line") - } - } - } - } -} - -func TestFormatSuggestions_MaxDisplay(t *testing.T) { - actions := make([]CodeAction, 10) - for i := range actions { - actions[i] = CodeAction{ - ID: "test", - Title: "Test action", - Category: "style", - File: "test.go", - Line: i + 1, - Priority: 5, - } - } - - output := FormatSuggestions(actions, 3) - if !strings.Contains(output, "... and 7 more suggestions") { - t.Errorf("expected truncation message, got:\n%s", output) - } -} - -func TestApplyFix(t *testing.T) { - content := `package main - -import "io/ioutil" - -func main() { - data, _ := ioutil.ReadFile("test.txt") - _ = data -} -` - action := CodeAction{ - ID: "go-ioutil-readfile", - Title: "Replace deprecated ioutil.ReadFile", - File: "main.go", - Line: 6, - Fix: "os.ReadFile", - } - - result, err := applyFixHelper(action, content) - if err != nil { - t.Fatalf("ApplyFix failed: %v", err) - } - if !strings.Contains(result, "os.ReadFile") { - t.Error("expected result to contain os.ReadFile") - } - if strings.Contains(result, "ioutil.ReadFile") { - t.Error("expected ioutil.ReadFile to be replaced") - } -} - -func applyFixHelper(action CodeAction, content string) (string, error) { - return ApplyFix(action, content) -} - -func TestApplyFix_InvalidLine(t *testing.T) { - content := "line1\nline2\n" - - action := CodeAction{ - ID: "test", - Line: 100, - Fix: "replacement", - } - - _, err := ApplyFix(action, content) - if err == nil { - t.Error("expected error for invalid line number") - } -} - -func TestApplyFix_NoFix(t *testing.T) { - content := "line1\nline2\n" - - action := CodeAction{ - ID: "test", - Line: 1, - Fix: "", - } - - _, err := ApplyFix(action, content) - if err == nil { - t.Error("expected error when no fix is available") - } -} - -func TestDetect_AntipatternSuppression(t *testing.T) { - detector := NewActionDetector() - - t.Run("proper except is not flagged", func(t *testing.T) { - content := `def safe(): - try: - do_stuff() - except ValueError: - pass -` - actions := detector.Detect("handler.py", content) - for _, a := range actions { - if a.ID == "py-bare-except" { - t.Error("py-bare-except should not fire when except has a type") - } - } - }) - - t.Run("strict equality not flagged", func(t *testing.T) { - content := `if (value === null) { return; } -` - actions := detector.Detect("app.ts", content) - for _, a := range actions { - if a.ID == "ts-loose-equality-null" { - t.Error("ts-loose-equality-null should not fire for === null") - } - } - }) -} - -func TestDetect_PriorityOrdering(t *testing.T) { - detector := NewActionDetector() - content := `package main - -// TODO: fix this -func process(data interface{}) { - password := "secret123" - if err != nil { return err } -} -` - actions := detector.Detect("main.go", content) - - if len(actions) < 2 { - t.Fatalf("expected at least 2 actions, got %d", len(actions)) - } - - // Verify sorted by priority (ascending) - for i := 1; i < len(actions); i++ { - if actions[i].Priority < actions[i-1].Priority { - t.Errorf("actions not sorted by priority: action[%d].Priority=%d < action[%d].Priority=%d", - i, actions[i].Priority, i-1, actions[i-1].Priority) - } - } -} - -func TestDetectForDiff(t *testing.T) { - detector := NewActionDetector() - diff := `diff --git a/handler.py b/handler.py -index abc123..def456 100644 ---- a/handler.py -+++ b/handler.py -@@ -10,6 +10,10 @@ def existing(): - pass - -+def risky(): -+ try: -+ do_stuff() -+ except: -+ pass -` - actions := detector.DetectForDiff(diff) - - found := false - for _, a := range actions { - if a.ID == "py-bare-except" { - found = true - break - } - } - if !found { - t.Error("expected py-bare-except to be detected in diff added lines") - } -} - -func TestDetect_SecurityPatterns(t *testing.T) { - detector := NewActionDetector() - content := `package main - -const dbPassword = "supersecret123" - -func query(userInput string) { - q := "SELECT * FROM users WHERE name=" + userInput - _ = q -} -` - actions := detector.Detect("main.go", content) - - var foundCredential, foundSQL bool - for _, a := range actions { - if a.ID == "hardcoded-credential" { - foundCredential = true - if a.Priority != 1 { - t.Errorf("expected priority 1 for security issue, got %d", a.Priority) - } - if a.Category != "security" { - t.Errorf("expected category 'security', got %q", a.Category) - } - } - if a.ID == "sql-injection" { - foundSQL = true - } - } - if !foundCredential { - t.Error("expected hardcoded-credential to be detected") - } - if !foundSQL { - t.Error("expected sql-injection to be detected") - } -} - -func TestDetect_LanguageFiltering(t *testing.T) { - detector := NewActionDetector() - - // Python rules should not fire on Go files - content := `except: - pass -` - actions := detector.Detect("main.go", content) - for _, a := range actions { - if a.ID == "py-bare-except" { - t.Error("python rule should not fire on .go file") - } - } -} - -func TestDetect_DeepNesting(t *testing.T) { - detector := NewActionDetector() - content := `package main - -func deep() { - if true { - if true { - if true { - if true { - if true { - doSomething() - } - } - } - } - } -} -` - actions := detector.Detect("main.go", content) - found := false - for _, a := range actions { - if a.ID == "deep-nesting" { - found = true - if a.Category != "refactor" { - t.Errorf("expected category 'refactor', got %q", a.Category) - } - break - } - } - if !found { - t.Error("expected deep-nesting action to be detected") - } -} - -func TestNewActionDetector_HasMinimumRules(t *testing.T) { - detector := NewActionDetector() - if len(detector.Rules) < 25 { - t.Errorf("expected at least 25 built-in rules, got %d", len(detector.Rules)) - } -} - -func TestDetect_EmptyContent(t *testing.T) { - detector := NewActionDetector() - actions := detector.Detect("empty.go", "") - if len(actions) != 0 { - t.Errorf("expected no actions for empty content, got %d", len(actions)) - } -} - -func TestFormatSuggestions_Empty(t *testing.T) { - output := FormatSuggestions(nil, 5) - if output != "" { - t.Errorf("expected empty output for nil actions, got %q", output) - } -} diff --git a/internal/engine/code/code_context.go b/internal/engine/code/code_context.go deleted file mode 100644 index 29f7e120c..000000000 --- a/internal/engine/code/code_context.go +++ /dev/null @@ -1,545 +0,0 @@ -package code - -import ( - "bufio" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" -) - -type CodeSnippet struct { - File string - StartLine int - EndLine int - Content string - Relevance float64 - Type string - Symbol string -} - -type CodeContext struct { - Snippets []CodeSnippet - TotalTokens int - Query string - mu sync.RWMutex -} - -type ContextExtractor struct { - ProjectDir string - MaxTokens int - mu sync.Mutex -} - -func NewContextExtractor(projectDir string, maxTokens int) *ContextExtractor { - if maxTokens <= 0 { - maxTokens = 8000 - } - return &ContextExtractor{ - ProjectDir: projectDir, - MaxTokens: maxTokens, - } -} - -func (ce *ContextExtractor) ExtractForTask(task string) (*CodeContext, error) { - ce.mu.Lock() - defer ce.mu.Unlock() - - ctx := &CodeContext{ - Query: task, - } - - symbols := ce.FindRelevantSymbols(task, 20) - if len(symbols) == 0 { - return ctx, nil - } - - ranked := ce.RankSnippets(symbols, task) - - totalTokens := 0 - for _, snip := range ranked { - tokens := codeCtxEstimateTokens(snip.Content) - if totalTokens+tokens > ce.MaxTokens { - break - } - totalTokens += tokens - ctx.Snippets = append(ctx.Snippets, snip) - } - ctx.TotalTokens = totalTokens - - return ctx, nil -} - -func (ce *ContextExtractor) ExtractFunction(file, funcName string) (*CodeSnippet, error) { - fullPath := ce.resolvePath(file) - lines, err := readFileLines(fullPath) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", file, err) - } - - funcPattern := regexp.MustCompile(`^func\s+(\([^)]*\)\s+)?` + regexp.QuoteMeta(funcName) + `\s*[\[(]`) - - startLine := -1 - for i, line := range lines { - if funcPattern.MatchString(line) { - startLine = i - break - } - } - if startLine == -1 { - return nil, fmt.Errorf("function %s not found in %s", funcName, file) - } - - endLine := findBlockEnd(lines, startLine) - - content := strings.Join(lines[startLine:endLine+1], "\n") - return &CodeSnippet{ - File: file, - StartLine: startLine + 1, - EndLine: endLine + 1, - Content: content, - Relevance: 1.0, - Type: "function", - Symbol: funcName, - }, nil -} - -func (ce *ContextExtractor) ExtractType(file, typeName string) (*CodeSnippet, error) { - fullPath := ce.resolvePath(file) - lines, err := readFileLines(fullPath) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", file, err) - } - - typePattern := regexp.MustCompile(`^type\s+` + regexp.QuoteMeta(typeName) + `\s+`) - startLine := -1 - for i, line := range lines { - if typePattern.MatchString(line) { - startLine = i - break - } - } - if startLine == -1 { - return nil, fmt.Errorf("type %s not found in %s", typeName, file) - } - - endLine := startLine - if strings.Contains(lines[startLine], "{") { - endLine = findBlockEnd(lines, startLine) - } - - methodPattern := regexp.MustCompile(`^func\s+\([^)]*\*?` + regexp.QuoteMeta(typeName) + `\)\s+`) - var methodBlocks []string - for i, line := range lines { - if i <= endLine { - continue - } - if methodPattern.MatchString(line) { - mEnd := findBlockEnd(lines, i) - methodBlocks = append(methodBlocks, strings.Join(lines[i:mEnd+1], "\n")) - } - } - - content := strings.Join(lines[startLine:endLine+1], "\n") - if len(methodBlocks) > 0 { - content += "\n\n" + strings.Join(methodBlocks, "\n\n") - } - - return &CodeSnippet{ - File: file, - StartLine: startLine + 1, - EndLine: endLine + 1, - Content: content, - Relevance: 1.0, - Type: "type", - Symbol: typeName, - }, nil -} - -func (ce *ContextExtractor) ExtractImports(file string) (*CodeSnippet, error) { - fullPath := ce.resolvePath(file) - lines, err := readFileLines(fullPath) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", file, err) - } - - startLine := -1 - endLine := -1 - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "import (" { - startLine = i - for j := i; j < len(lines); j++ { - if strings.TrimSpace(lines[j]) == ")" { - endLine = j - break - } - } - break - } else if strings.HasPrefix(trimmed, "import ") && !strings.Contains(trimmed, "(") { - startLine = i - endLine = i - break - } - } - - if startLine == -1 { - return nil, fmt.Errorf("no import block found in %s", file) - } - - content := strings.Join(lines[startLine:endLine+1], "\n") - return &CodeSnippet{ - File: file, - StartLine: startLine + 1, - EndLine: endLine + 1, - Content: content, - Relevance: 0.5, - Type: "import", - Symbol: "imports", - }, nil -} - -func (ce *ContextExtractor) ExtractSurrounding(file string, line, contextLines int) (*CodeSnippet, error) { - fullPath := ce.resolvePath(file) - lines, err := readFileLines(fullPath) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", file, err) - } - - if line < 1 || line > len(lines) { - return nil, fmt.Errorf("line %d out of range (file has %d lines)", line, len(lines)) - } - - start := line - 1 - contextLines - if start < 0 { - start = 0 - } - end := line - 1 + contextLines - if end >= len(lines) { - end = len(lines) - 1 - } - - content := strings.Join(lines[start:end+1], "\n") - return &CodeSnippet{ - File: file, - StartLine: start + 1, - EndLine: end + 1, - Content: content, - Relevance: 0.7, - Type: "block", - Symbol: fmt.Sprintf("L%d±%d", line, contextLines), - }, nil -} - -func (ce *ContextExtractor) FindRelevantSymbols(query string, limit int) []CodeSnippet { - keywords := extractKeywords(query) - if len(keywords) == 0 { - return nil - } - - var allSnippets []CodeSnippet - - matchedFiles := ce.grepForFiles(keywords) - - for _, file := range matchedFiles { - snippets := ce.extractSymbolsFromFile(file, keywords) - allSnippets = append(allSnippets, snippets...) - } - - seen := make(map[string]bool) - var unique []CodeSnippet - for _, s := range allSnippets { - key := s.File + ":" + s.Symbol - if !seen[key] { - seen[key] = true - unique = append(unique, s) - } - } - - if len(unique) > limit { - unique = unique[:limit] - } - return unique -} - -func (ce *ContextExtractor) RankSnippets(snippets []CodeSnippet, query string) []CodeSnippet { - keywords := extractKeywords(query) - if len(keywords) == 0 { - return snippets - } - - for i := range snippets { - score := scoreSnippet(&snippets[i], keywords) - snippets[i].Relevance = score - } - - sort.Slice(snippets, func(i, j int) bool { - return snippets[i].Relevance > snippets[j].Relevance - }) - - return snippets -} - -func FormatContext(ctx *CodeContext) string { - if ctx == nil || len(ctx.Snippets) == 0 { - return "" - } - - ctx.mu.RLock() - defer ctx.mu.RUnlock() - - var sb strings.Builder - sb.WriteString("## Relevant Code Context\n\n") - - for _, snip := range ctx.Snippets { - header := snip.File - if snip.Symbol != "" { - header += ":" + snip.Symbol - } - sb.WriteString(fmt.Sprintf("### %s (relevance: %.2f)\n", header, snip.Relevance)) - sb.WriteString("```go\n") - sb.WriteString(snip.Content) - if !strings.HasSuffix(snip.Content, "\n") { - sb.WriteString("\n") - } - sb.WriteString("```\n\n") - } - - return sb.String() -} - -func codeCtxEstimateTokens(content string) int { - if content == "" { - return 0 - } - chars := len(content) - tokens := (chars + 3) / 4 - if tokens == 0 { - tokens = 1 - } - return tokens -} - -func (ce *ContextExtractor) resolvePath(file string) string { - if filepath.IsAbs(file) { - return file - } - return filepath.Join(ce.ProjectDir, file) -} - -func readFileLines(path string) ([]string, error) { - f, err := os.Open(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - var lines []string - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return lines, nil -} - -func findBlockEnd(lines []string, startLine int) int { - depth := 0 - for i := startLine; i < len(lines); i++ { - for _, ch := range lines[i] { - if ch == '{' { - depth++ - } else if ch == '}' { - depth-- - if depth == 0 { - return i - } - } - } - } - return len(lines) - 1 -} - -func extractKeywords(query string) []string { - stopWords := map[string]bool{ - "the": true, "a": true, "an": true, "is": true, "are": true, - "was": true, "were": true, "be": true, "been": true, "being": true, - "have": true, "has": true, "had": true, "do": true, "does": true, - "did": true, "will": true, "would": true, "could": true, "should": true, - "may": true, "might": true, "shall": true, "can": true, "need": true, - "to": true, "of": true, "in": true, "for": true, "on": true, - "with": true, "at": true, "by": true, "from": true, "that": true, - "this": true, "it": true, "and": true, "or": true, "but": true, - "not": true, "if": true, "then": true, "else": true, "when": true, - "up": true, "so": true, "no": true, "as": true, "i": true, - "me": true, "my": true, "we": true, "our": true, "you": true, - "your": true, "they": true, "them": true, "their": true, - } - - splitter := regexp.MustCompile(`[^a-zA-Z0-9_]+`) - words := splitter.Split(strings.ToLower(query), -1) - - var keywords []string - seen := make(map[string]bool) - for _, w := range words { - if len(w) < 2 { - continue - } - if stopWords[w] { - continue - } - if !seen[w] { - seen[w] = true - keywords = append(keywords, w) - } - } - return keywords -} - -func (ce *ContextExtractor) grepForFiles(keywords []string) []string { - if len(keywords) == 0 { - return nil - } - - pattern := strings.Join(keywords, "|") - - cmd := exec.CommandContext(context.Background(), "grep", "-rl", "--include=*.go", "-E", pattern, ce.ProjectDir) // #nosec G204 -- invocation of standard unix utility with internally-derived path/pattern - out, err := cmd.Output() - if err != nil { - return nil - } - - var files []string - seen := make(map[string]bool) - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if line == "" { - continue - } - rel, err := filepath.Rel(ce.ProjectDir, line) - if err != nil { - rel = line - } - if !seen[rel] { - seen[rel] = true - files = append(files, rel) - } - } - - if len(files) > 30 { - files = files[:30] - } - return files -} - -func (ce *ContextExtractor) extractSymbolsFromFile(file string, keywords []string) []CodeSnippet { - fullPath := ce.resolvePath(file) - lines, err := readFileLines(fullPath) - if err != nil { - return nil - } - - funcPattern := regexp.MustCompile(`^func\s+(?:\([^)]*\)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[\[(]`) - typePattern := regexp.MustCompile(`^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) - - var snippets []CodeSnippet - - for i, line := range lines { - if matches := funcPattern.FindStringSubmatch(line); matches != nil { - funcName := matches[1] - if symbolMatchesKeywords(funcName, keywords) { - endLine := findBlockEnd(lines, i) - content := strings.Join(lines[i:endLine+1], "\n") - snippets = append(snippets, CodeSnippet{ - File: file, - StartLine: i + 1, - EndLine: endLine + 1, - Content: content, - Type: "function", - Symbol: funcName, - }) - } - } - - if matches := typePattern.FindStringSubmatch(line); matches != nil { - typeName := matches[1] - if symbolMatchesKeywords(typeName, keywords) { - endLine := i - if strings.Contains(line, "{") { - endLine = findBlockEnd(lines, i) - } - content := strings.Join(lines[i:endLine+1], "\n") - snippets = append(snippets, CodeSnippet{ - File: file, - StartLine: i + 1, - EndLine: endLine + 1, - Content: content, - Type: "type", - Symbol: typeName, - }) - } - } - } - - return snippets -} - -func symbolMatchesKeywords(symbol string, keywords []string) bool { - lower := strings.ToLower(symbol) - for _, kw := range keywords { - if strings.Contains(lower, kw) { - return true - } - } - return false -} - -func scoreSnippet(snip *CodeSnippet, keywords []string) float64 { - if len(keywords) == 0 { - return 0.0 - } - - score := 0.0 - lowerContent := strings.ToLower(snip.Content) - lowerSymbol := strings.ToLower(snip.Symbol) - - matchCount := 0 - for _, kw := range keywords { - if strings.Contains(lowerContent, kw) { - matchCount++ - } - if strings.Contains(lowerSymbol, kw) { - score += 0.15 - } - } - score += float64(matchCount) / float64(len(keywords)) * 0.5 - - if len(snip.Symbol) > 0 && snip.Symbol[0] >= 'A' && snip.Symbol[0] <= 'Z' { - score += 0.1 - } - - if snip.Type == "function" { - score += 0.05 - } - - lineCount := snip.EndLine - snip.StartLine + 1 - if lineCount > 100 { - score -= 0.1 - } - - if score > 1.0 { - score = 1.0 - } - if score < 0.0 { - score = 0.0 - } - return score -} diff --git a/internal/engine/code/code_context_test.go b/internal/engine/code/code_context_test.go deleted file mode 100644 index 285dd02f6..000000000 --- a/internal/engine/code/code_context_test.go +++ /dev/null @@ -1,536 +0,0 @@ -package code - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestNewContextExtractor(t *testing.T) { - ce := NewContextExtractor("/tmp/project", 4000) - if ce.ProjectDir != "/tmp/project" { - t.Errorf("ProjectDir = %q, want /tmp/project", ce.ProjectDir) - } - if ce.MaxTokens != 4000 { - t.Errorf("MaxTokens = %d, want 4000", ce.MaxTokens) - } -} - -func TestNewContextExtractorDefaultTokens(t *testing.T) { - ce := NewContextExtractor("/tmp/project", 0) - if ce.MaxTokens != 8000 { - t.Errorf("MaxTokens = %d, want 8000 (default)", ce.MaxTokens) - } - - ce2 := NewContextExtractor("/tmp/project", -1) - if ce2.MaxTokens != 8000 { - t.Errorf("MaxTokens = %d, want 8000 (default for negative)", ce2.MaxTokens) - } -} - -func TestEstimateTokens(t *testing.T) { - tests := []struct { - input string - min int - max int - }{ - {"", 0, 0}, - {"x", 1, 1}, - {"hello world", 2, 4}, - {strings.Repeat("a", 100), 20, 30}, - {"func main() {\n\tfmt.Println(\"hello\")\n}", 5, 15}, - } - for _, tt := range tests { - got := codeCtxEstimateTokens(tt.input) - if got < tt.min || got > tt.max { - t.Errorf("codeCtxEstimateTokens(%q) = %d, want in [%d, %d]", tt.input, got, tt.min, tt.max) - } - } -} - -func TestExtractKeywords(t *testing.T) { - tests := []struct { - query string - contains []string - excludes []string - }{ - { - "fix the authentication token validation", - []string{"fix", "authentication", "token", "validation"}, - []string{"the"}, - }, - { - "add a new handler for user requests", - []string{"add", "new", "handler", "user", "requests"}, - []string{"a", "for"}, - }, - { - "", - nil, - nil, - }, - } - for _, tt := range tests { - got := extractKeywords(tt.query) - for _, want := range tt.contains { - found := false - for _, kw := range got { - if kw == want { - found = true - break - } - } - if !found { - t.Errorf("extractKeywords(%q) missing keyword %q, got %v", tt.query, want, got) - } - } - for _, exclude := range tt.excludes { - for _, kw := range got { - if kw == exclude { - t.Errorf("extractKeywords(%q) should not contain stop word %q", tt.query, exclude) - } - } - } - } -} - -func TestSymbolMatchesKeywords(t *testing.T) { - tests := []struct { - symbol string - keywords []string - want bool - }{ - {"ValidateToken", []string{"validate", "token"}, true}, - {"HandleRequest", []string{"handle"}, true}, - {"HandleRequest", []string{"token"}, false}, - {"parseConfig", []string{"config"}, true}, - {"", []string{"anything"}, false}, - } - for _, tt := range tests { - got := symbolMatchesKeywords(tt.symbol, tt.keywords) - if got != tt.want { - t.Errorf("symbolMatchesKeywords(%q, %v) = %v, want %v", tt.symbol, tt.keywords, got, tt.want) - } - } -} - -func TestFindBlockEnd(t *testing.T) { - lines := []string{ - "func foo() {", - " if true {", - " bar()", - " }", - " baz()", - "}", - "", - "func next() {", - } - end := findBlockEnd(lines, 0) - if end != 5 { - t.Errorf("findBlockEnd = %d, want 5", end) - } - - // Nested block - end = findBlockEnd(lines, 1) - if end != 3 { - t.Errorf("findBlockEnd for nested = %d, want 3", end) - } -} - -func TestScoreSnippet(t *testing.T) { - snip := &CodeSnippet{ - File: "auth/token.go", - StartLine: 10, - EndLine: 25, - Content: "func ValidateToken(token string) error {\n\t// validate the token\n\treturn nil\n}", - Type: "function", - Symbol: "ValidateToken", - } - - keywords := []string{"validate", "token"} - score := scoreSnippet(snip, keywords) - - if score <= 0.0 { - t.Errorf("score should be positive, got %f", score) - } - if score > 1.0 { - t.Errorf("score should be <= 1.0, got %f", score) - } - - // Exported symbol should score higher than unexported - snipUnexported := &CodeSnippet{ - File: "auth/token.go", - StartLine: 10, - EndLine: 25, - Content: "func validateToken(token string) error {\n\t// validate the token\n\treturn nil\n}", - Type: "function", - Symbol: "validateToken", - } - scoreUnexp := scoreSnippet(snipUnexported, keywords) - if score <= scoreUnexp { - t.Errorf("exported symbol score (%f) should be > unexported (%f)", score, scoreUnexp) - } -} - -func TestRankSnippets(t *testing.T) { - ce := NewContextExtractor("/tmp", 8000) - snippets := []CodeSnippet{ - {Symbol: "HandleRequest", Content: "func HandleRequest() {}", Type: "function"}, - {Symbol: "ValidateToken", Content: "func ValidateToken(token string) error { return nil }", Type: "function"}, - {Symbol: "Config", Content: "type Config struct {}", Type: "type"}, - } - - ranked := ce.RankSnippets(snippets, "validate token authentication") - - if len(ranked) != 3 { - t.Fatalf("expected 3 snippets, got %d", len(ranked)) - } - // ValidateToken should rank highest for "validate token" query - if ranked[0].Symbol != "ValidateToken" { - t.Errorf("expected ValidateToken first, got %s", ranked[0].Symbol) - } -} - -// setupTestProject creates a temporary Go project for testing. -func setupTestProject(t *testing.T) string { - t.Helper() - dir := t.TempDir() - - // Create a sample Go file - authDir := filepath.Join(dir, "auth") - if err := os.MkdirAll(authDir, 0o755); err != nil { - t.Fatal(err) - } - - tokenFile := filepath.Join(authDir, "token.go") - content := `package auth - -import ( - "errors" - "strings" -) - -// Claims holds token claims. -type Claims struct { - UserID string - Role string -} - -// ValidateToken validates a JWT token and returns its claims. -func ValidateToken(token string) (*Claims, error) { - if token == "" { - return nil, errors.New("empty token") - } - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, errors.New("invalid token format") - } - return &Claims{UserID: "user1", Role: "admin"}, nil -} - -// RefreshToken refreshes an existing token. -func RefreshToken(token string) (string, error) { - if token == "" { - return "", errors.New("empty token") - } - return "new-token", nil -} - -func internalHelper() string { - return "helper" -} -` - if err := os.WriteFile(tokenFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - // Create a handler file - handlerDir := filepath.Join(dir, "handler") - if err := os.MkdirAll(handlerDir, 0o755); err != nil { - t.Fatal(err) - } - - handlerFile := filepath.Join(handlerDir, "api.go") - handlerContent := `package handler - -import "fmt" - -// Handler processes API requests. -type Handler struct { - Name string -} - -// HandleRequest processes an incoming request. -func (h *Handler) HandleRequest(path string) string { - return fmt.Sprintf("handling %s", path) -} - -// NewHandler creates a new Handler. -func NewHandler(name string) *Handler { - return &Handler{Name: name} -} -` - if err := os.WriteFile(handlerFile, []byte(handlerContent), 0o644); err != nil { - t.Fatal(err) - } - - return dir -} - -func TestExtractFunction(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - snip, err := ce.ExtractFunction("auth/token.go", "ValidateToken") - if err != nil { - t.Fatalf("ExtractFunction: %v", err) - } - - if snip.Symbol != "ValidateToken" { - t.Errorf("Symbol = %q, want ValidateToken", snip.Symbol) - } - if snip.Type != "function" { - t.Errorf("Type = %q, want function", snip.Type) - } - if !strings.Contains(snip.Content, "func ValidateToken") { - t.Error("content should contain function signature") - } - if !strings.Contains(snip.Content, "return &Claims") { - t.Error("content should contain function body") - } - if snip.StartLine < 1 { - t.Errorf("StartLine = %d, want >= 1", snip.StartLine) - } -} - -func TestExtractFunctionNotFound(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - _, err := ce.ExtractFunction("auth/token.go", "NonExistentFunc") - if err == nil { - t.Error("expected error for non-existent function") - } -} - -func TestExtractType(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - snip, err := ce.ExtractType("auth/token.go", "Claims") - if err != nil { - t.Fatalf("ExtractType: %v", err) - } - - if snip.Symbol != "Claims" { - t.Errorf("Symbol = %q, want Claims", snip.Symbol) - } - if snip.Type != "type" { - t.Errorf("Type = %q, want type", snip.Type) - } - if !strings.Contains(snip.Content, "type Claims struct") { - t.Error("content should contain type definition") - } -} - -func TestExtractTypeWithMethods(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - snip, err := ce.ExtractType("handler/api.go", "Handler") - if err != nil { - t.Fatalf("ExtractType: %v", err) - } - - if snip.Symbol != "Handler" { - t.Errorf("Symbol = %q, want Handler", snip.Symbol) - } - if !strings.Contains(snip.Content, "type Handler struct") { - t.Error("content should contain type definition") - } - if !strings.Contains(snip.Content, "func (h *Handler) HandleRequest") { - t.Error("content should contain method") - } -} - -func TestExtractImports(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - snip, err := ce.ExtractImports("auth/token.go") - if err != nil { - t.Fatalf("ExtractImports: %v", err) - } - - if snip.Type != "import" { - t.Errorf("Type = %q, want import", snip.Type) - } - if !strings.Contains(snip.Content, "errors") { - t.Error("content should contain 'errors' import") - } - if !strings.Contains(snip.Content, "strings") { - t.Error("content should contain 'strings' import") - } -} - -func TestExtractSurrounding(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - snip, err := ce.ExtractSurrounding("auth/token.go", 10, 2) - if err != nil { - t.Fatalf("ExtractSurrounding: %v", err) - } - - if snip.Type != "block" { - t.Errorf("Type = %q, want block", snip.Type) - } - // Should have at most 5 lines (target ± 2) - lines := strings.Split(snip.Content, "\n") - if len(lines) > 5 { - t.Errorf("expected at most 5 lines, got %d", len(lines)) - } - if snip.StartLine > 10 || snip.EndLine < 10 { - t.Errorf("range [%d, %d] should contain line 10", snip.StartLine, snip.EndLine) - } -} - -func TestExtractSurroundingOutOfRange(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - _, err := ce.ExtractSurrounding("auth/token.go", 9999, 2) - if err == nil { - t.Error("expected error for out-of-range line") - } -} - -func TestFormatContext(t *testing.T) { - ctx := &CodeContext{ - Query: "validate token", - Snippets: []CodeSnippet{ - { - File: "auth/token.go", - Symbol: "ValidateToken", - Relevance: 0.95, - Content: "func ValidateToken(token string) (*Claims, error) {\n ...\n}", - Type: "function", - }, - { - File: "handler/api.go", - Symbol: "HandleRequest", - Relevance: 0.82, - Content: "func HandleRequest(w http.ResponseWriter, r *http.Request) {\n ...\n}", - Type: "function", - }, - }, - } - - formatted := FormatContext(ctx) - - if !strings.Contains(formatted, "## Relevant Code Context") { - t.Error("missing header") - } - if !strings.Contains(formatted, "auth/token.go:ValidateToken (relevance: 0.95)") { - t.Error("missing first snippet header") - } - if !strings.Contains(formatted, "handler/api.go:HandleRequest (relevance: 0.82)") { - t.Error("missing second snippet header") - } - if !strings.Contains(formatted, "```go") { - t.Error("missing code fence") - } -} - -func TestFormatContextNil(t *testing.T) { - if got := FormatContext(nil); got != "" { - t.Errorf("FormatContext(nil) = %q, want empty", got) - } - if got := FormatContext(&CodeContext{}); got != "" { - t.Errorf("FormatContext(empty) = %q, want empty", got) - } -} - -func TestFindRelevantSymbols(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - symbols := ce.FindRelevantSymbols("validate token", 10) - if len(symbols) == 0 { - t.Fatal("expected at least one symbol") - } - - // Should find ValidateToken - found := false - for _, s := range symbols { - if s.Symbol == "ValidateToken" { - found = true - break - } - } - if !found { - t.Error("expected to find ValidateToken symbol") - } -} - -func TestFindRelevantSymbolsEmptyQuery(t *testing.T) { - ce := NewContextExtractor("/tmp", 8000) - symbols := ce.FindRelevantSymbols("", 10) - if len(symbols) != 0 { - t.Errorf("expected no symbols for empty query, got %d", len(symbols)) - } -} - -func TestExtractForTask(t *testing.T) { - dir := setupTestProject(t) - ce := NewContextExtractor(dir, 8000) - - ctx, err := ce.ExtractForTask("fix the token validation logic") - if err != nil { - t.Fatalf("ExtractForTask: %v", err) - } - - if ctx.Query != "fix the token validation logic" { - t.Errorf("Query = %q, want original task", ctx.Query) - } - if ctx.TotalTokens < 0 { - t.Errorf("TotalTokens = %d, should be non-negative", ctx.TotalTokens) - } -} - -func TestExtractForTaskTokenBudget(t *testing.T) { - dir := setupTestProject(t) - // Use a very small token budget - ce := NewContextExtractor(dir, 50) - - ctx, err := ce.ExtractForTask("validate token handler request") - if err != nil { - t.Fatalf("ExtractForTask: %v", err) - } - - if ctx.TotalTokens > 50 { - t.Errorf("TotalTokens = %d, should respect MaxTokens of 50", ctx.TotalTokens) - } -} - -func TestCodeContextConcurrency(t *testing.T) { - ctx := &CodeContext{ - Query: "test", - Snippets: []CodeSnippet{ - {Symbol: "Foo", Content: "func Foo() {}", Relevance: 0.9}, - }, - } - - // Test concurrent read access via FormatContext - done := make(chan bool, 10) - for i := 0; i < 10; i++ { - go func() { - _ = FormatContext(ctx) - done <- true - }() - } - for i := 0; i < 10; i++ { - <-done - } -} diff --git a/internal/engine/code/code_explainer.go b/internal/engine/code/code_explainer.go deleted file mode 100644 index 531c552dd..000000000 --- a/internal/engine/code/code_explainer.go +++ /dev/null @@ -1,610 +0,0 @@ -package code - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "regexp" - "strings" - "sync" -) - -type CodeExplanation struct { - File string - Symbol string - Summary string - Sections []ExplanationSection - Complexity string - Dependencies []string - UsedBy []string -} - -type ExplanationSection struct { - Title string - Content string - CodeRef string -} - -type CodeExplainer struct { - mu sync.Mutex -} - -func NewCodeExplainer() *CodeExplainer { - return &CodeExplainer{} -} - -func (ce *CodeExplainer) ExplainFunction(file, content, funcName string) (*CodeExplanation, error) { - ce.mu.Lock() - defer ce.mu.Unlock() - - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, file, content, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse error: %w", err) - } - - var funcDecl *ast.FuncDecl - for _, decl := range f.Decls { - fd, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if fd.Name.Name == funcName { - funcDecl = fd - break - } - } - if funcDecl == nil { - return nil, fmt.Errorf("function %q not found in %s", funcName, file) - } - - params := explainerExtractParams(funcDecl) - returns := extractReturns(funcDecl) - docComment := explainerExtractDocComment(funcDecl) - - paramTypes := make([]string, 0, len(params)) - for _, p := range params { - paramTypes = append(paramTypes, p[1]) - } - purpose := InferPurpose(funcName, paramTypes, returns) - if docComment != "" { - purpose = docComment - } - - var sections []ExplanationSection - - sections = append(sections, ExplanationSection{ - Title: "Purpose", - Content: purpose, - }) - - if len(params) > 0 { - var paramLines []string - for _, p := range params { - desc := inferParamPurpose(p[0], p[1]) - paramLines = append(paramLines, fmt.Sprintf("- `%s %s` — %s", p[0], p[1], desc)) - } - sections = append(sections, ExplanationSection{ - Title: "Parameters", - Content: strings.Join(paramLines, "\n"), - }) - } - - if len(returns) > 0 { - sections = append(sections, ExplanationSection{ - Title: "Returns", - Content: "`" + strings.Join(returns, ", ") + "`", - }) - } - - funcBody := extractFuncBody(content, funcDecl, fset) - controlFlow := DescribeControlFlow(funcBody) - sections = append(sections, ExplanationSection{ - Title: "Control Flow", - Content: controlFlow, - }) - - errHandling := describeErrorHandling(funcBody) - sections = append(sections, ExplanationSection{ - Title: "Error Handling", - Content: errHandling, - }) - - sideEffects := DetectSideEffects(funcBody) - sideEffectStr := "None (pure function)" - if len(sideEffects) > 0 { - sideEffectStr = strings.Join(sideEffects, ", ") - } - sections = append(sections, ExplanationSection{ - Title: "Side Effects", - Content: sideEffectStr, - }) - - cc := computeCyclomaticComplexity(funcBody) - complexity := classifyComplexity(cc) - - deps := extractDependencies(funcBody) - - return &CodeExplanation{ - File: file, - Symbol: funcName, - Summary: purpose, - Sections: sections, - Complexity: fmt.Sprintf("%s (CC: %d)", complexity, cc), - Dependencies: deps, - UsedBy: nil, - }, nil -} - -func (ce *CodeExplainer) ExplainType(file, content, typeName string) (*CodeExplanation, error) { - ce.mu.Lock() - defer ce.mu.Unlock() - - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, file, content, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse error: %w", err) - } - - var typeSpec *ast.TypeSpec - var genDecl *ast.GenDecl - for _, decl := range f.Decls { - gd, ok := decl.(*ast.GenDecl) - if !ok { - continue - } - for _, spec := range gd.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - if ts.Name.Name == typeName { - typeSpec = ts - genDecl = gd - break - } - } - if typeSpec != nil { - break - } - } - if typeSpec == nil { - return nil, fmt.Errorf("type %q not found in %s", typeName, file) - } - - var sections []ExplanationSection - - docComment := "" - if genDecl.Doc != nil { - docComment = strings.TrimSpace(genDecl.Doc.Text()) - } - if typeSpec.Doc != nil { - docComment = strings.TrimSpace(typeSpec.Doc.Text()) - } - - purpose := inferTypePurpose(typeName) - if docComment != "" { - purpose = docComment - } - sections = append(sections, ExplanationSection{ - Title: "Purpose", - Content: purpose, - }) - - if st, ok := typeSpec.Type.(*ast.StructType); ok { - var fieldLines []string - for _, field := range st.Fields.List { - typeStr := explainerExprToString(field.Type) - for _, name := range field.Names { - desc := inferFieldPurpose(name.Name, typeStr) - fieldLines = append(fieldLines, fmt.Sprintf("- `%s %s` — %s", name.Name, typeStr, desc)) - } - if len(field.Names) == 0 { - fieldLines = append(fieldLines, fmt.Sprintf("- `%s` (embedded)", typeStr)) - } - } - if len(fieldLines) > 0 { - sections = append(sections, ExplanationSection{ - Title: "Fields", - Content: strings.Join(fieldLines, "\n"), - }) - } - } - - if iface, ok := typeSpec.Type.(*ast.InterfaceType); ok { - var methodLines []string - for _, method := range iface.Methods.List { - if len(method.Names) > 0 { - methodLines = append(methodLines, fmt.Sprintf("- `%s`", method.Names[0].Name)) - } - } - if len(methodLines) > 0 { - sections = append(sections, ExplanationSection{ - Title: "Methods", - Content: strings.Join(methodLines, "\n"), - }) - } - } - - var methods []string - for _, decl := range f.Decls { - fd, ok := decl.(*ast.FuncDecl) - if !ok || fd.Recv == nil { - continue - } - for _, recv := range fd.Recv.List { - recvType := explainerExprToString(recv.Type) - recvType = strings.TrimPrefix(recvType, "*") - if recvType == typeName { - methods = append(methods, fd.Name.Name) - } - } - } - if len(methods) > 0 { - var methodLines []string - for _, m := range methods { - methodLines = append(methodLines, fmt.Sprintf("- `%s`", m)) - } - sections = append(sections, ExplanationSection{ - Title: "Methods", - Content: strings.Join(methodLines, "\n"), - }) - } - - constructor := findConstructor(f, typeName) - if constructor != "" { - sections = append(sections, ExplanationSection{ - Title: "Constructor", - Content: fmt.Sprintf("Use `%s` to create instances", constructor), - }) - } - - interfaces := detectImplementedInterfaces(f, typeName, methods) - if len(interfaces) > 0 { - sections = append(sections, ExplanationSection{ - Title: "Implements", - Content: strings.Join(interfaces, ", "), - }) - } - - return &CodeExplanation{ - File: file, - Symbol: typeName, - Summary: purpose, - Sections: sections, - Complexity: "", - Dependencies: nil, - UsedBy: nil, - }, nil -} - -func (ce *CodeExplainer) ExplainFile(path, content string) (*CodeExplanation, error) { - ce.mu.Lock() - defer ce.mu.Unlock() - - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, path, content, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse error: %w", err) - } - - var sections []ExplanationSection - - pkgName := f.Name.Name - pkgDoc := "" - if f.Doc != nil { - pkgDoc = strings.TrimSpace(f.Doc.Text()) - } - pkgPurpose := fmt.Sprintf("Package `%s`", pkgName) - if pkgDoc != "" { - pkgPurpose = pkgDoc - } - sections = append(sections, ExplanationSection{ - Title: "Package Purpose", - Content: pkgPurpose, - }) - - var exportedFuncs []string - var exportedTypes []string - var internalFuncs []string - var internalTypes []string - - for _, decl := range f.Decls { - switch d := decl.(type) { - case *ast.FuncDecl: - if d.Name.IsExported() { - sig := funcSignature(d) - exportedFuncs = append(exportedFuncs, sig) - } else { - internalFuncs = append(internalFuncs, d.Name.Name) - } - case *ast.GenDecl: - for _, spec := range d.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - if ts.Name.IsExported() { - exportedTypes = append(exportedTypes, ts.Name.Name) - } else { - internalTypes = append(internalTypes, ts.Name.Name) - } - } - } - } - - if len(exportedTypes) > 0 || len(exportedFuncs) > 0 { - var apiLines []string - for _, t := range exportedTypes { - apiLines = append(apiLines, fmt.Sprintf("- type `%s`", t)) - } - for _, fn := range exportedFuncs { - apiLines = append(apiLines, fmt.Sprintf("- `%s`", fn)) - } - sections = append(sections, ExplanationSection{ - Title: "Exported API", - Content: strings.Join(apiLines, "\n"), - }) - } - - if len(internalTypes) > 0 || len(internalFuncs) > 0 { - var internalLines []string - for _, t := range internalTypes { - internalLines = append(internalLines, fmt.Sprintf("- type `%s`", t)) - } - for _, fn := range internalFuncs { - internalLines = append(internalLines, fmt.Sprintf("- `%s`", fn)) - } - sections = append(sections, ExplanationSection{ - Title: "Internal Structure", - Content: strings.Join(internalLines, "\n"), - }) - } - - patterns := detectPatterns(content) - if len(patterns) > 0 { - sections = append(sections, ExplanationSection{ - Title: "Key Patterns", - Content: strings.Join(patterns, "\n"), - }) - } - - summary := pkgPurpose - if len(exportedFuncs)+len(exportedTypes) > 0 { - summary = fmt.Sprintf("%s — exports %d types, %d functions", - pkgPurpose, len(exportedTypes), len(exportedFuncs)) - } - - return &CodeExplanation{ - File: path, - Symbol: pkgName, - Summary: summary, - Sections: sections, - }, nil -} - -func InferPurpose(name string, params, returns []string) string { - lower := strings.ToLower(name) - words := splitCamelCase(name) - verb := "" - if len(words) > 0 { - verb = strings.ToLower(words[0]) - } - object := "" - if len(words) > 1 { - object = strings.Join(words[1:], " ") - } - - hasError := containsType(returns, "error") - hasBool := containsType(returns, "bool") - - switch verb { - case "new": - return fmt.Sprintf("Creates a new %s instance", object) - case "get", "fetch", "load", "read": - if hasError { - return fmt.Sprintf("Retrieves %s, returning an error on failure", lowerFirst(object)) - } - return fmt.Sprintf("Retrieves %s", lowerFirst(object)) - case "set", "update", "put": - if hasError { - return fmt.Sprintf("Updates %s, returning an error on failure", lowerFirst(object)) - } - return fmt.Sprintf("Updates %s", lowerFirst(object)) - case "delete", "remove": - if hasError { - return fmt.Sprintf("Removes %s, returning an error on failure", lowerFirst(object)) - } - return fmt.Sprintf("Removes %s", lowerFirst(object)) - case "validate", "check", "verify": - if hasError { - return fmt.Sprintf("Validates %s and returns an error if invalid", lowerFirst(object)) - } - if hasBool { - return fmt.Sprintf("Checks whether %s is valid", lowerFirst(object)) - } - return fmt.Sprintf("Validates %s", lowerFirst(object)) - case "is", "has", "can", "should": - return fmt.Sprintf("Returns whether %s", lowerFirst(object)) - case "parse": - if hasError { - return fmt.Sprintf("Parses %s from input, returning an error if malformed", lowerFirst(object)) - } - return fmt.Sprintf("Parses %s from input", lowerFirst(object)) - case "convert", "to", "from": - return fmt.Sprintf("Converts %s between representations", lowerFirst(object)) - case "init", "initialize", "setup": - return fmt.Sprintf("Initializes %s", lowerFirst(object)) - case "close", "shutdown", "stop": - return fmt.Sprintf("Shuts down %s and releases resources", lowerFirst(object)) - case "start", "run", "execute": - return fmt.Sprintf("Starts or executes %s", lowerFirst(object)) - case "write", "save", "store": - if hasError { - return fmt.Sprintf("Persists %s, returning an error on failure", lowerFirst(object)) - } - return fmt.Sprintf("Persists %s", lowerFirst(object)) - case "find", "search", "lookup": - return fmt.Sprintf("Searches for %s matching the given criteria", lowerFirst(object)) - case "format", "render": - return fmt.Sprintf("Formats %s for display or output", lowerFirst(object)) - case "handle": - return fmt.Sprintf("Handles %s events or requests", lowerFirst(object)) - case "register", "add": - return fmt.Sprintf("Registers or adds %s", lowerFirst(object)) - } - - if strings.Contains(lower, "string") && containsType(returns, "string") { - return fmt.Sprintf("Converts %s to its string representation", lowerFirst(name)) - } - - if hasError && len(params) > 0 { - return fmt.Sprintf("Performs %s on the given input, returning an error on failure", - lowerFirst(strings.Join(words, " "))) - } - - return fmt.Sprintf("Performs %s", lowerFirst(strings.Join(words, " "))) -} - -func DescribeControlFlow(funcBody string) string { - hasFor := regexp.MustCompile(`\bfor\b`).MatchString(funcBody) - hasRange := regexp.MustCompile(`\brange\b`).MatchString(funcBody) - hasSwitch := regexp.MustCompile(`\bswitch\b`).MatchString(funcBody) - hasSelect := regexp.MustCompile(`\bselect\b`).MatchString(funcBody) - hasIf := regexp.MustCompile(`\bif\b`).MatchString(funcBody) - hasErrReturn := regexp.MustCompile(`return\s+.*err`).MatchString(funcBody) - hasBreak := regexp.MustCompile(`\bbreak\b`).MatchString(funcBody) - hasContinue := regexp.MustCompile(`\bcontinue\b`).MatchString(funcBody) - hasGoto := regexp.MustCompile(`\bgoto\b`).MatchString(funcBody) - hasDefer := regexp.MustCompile(`\bdefer\b`).MatchString(funcBody) - hasGo := regexp.MustCompile(`\bgo\s+`).MatchString(funcBody) - hasRecursion := regexp.MustCompile(`\b\w+\(`).MatchString(funcBody) - - var parts []string - - if hasSelect { - parts = append(parts, "Channel select with multiple cases") - } else if hasSwitch { - parts = append(parts, "Switch dispatch") - } else if hasFor && hasRange { - if hasBreak { - parts = append(parts, "Range loop with conditional break") - } else if hasContinue { - parts = append(parts, "Range loop with conditional skip") - } else { - parts = append(parts, "Range iteration") - } - } else if hasFor { - if hasBreak { - parts = append(parts, "Loop with conditional break") - } else { - parts = append(parts, "Loop-based processing") - } - } else if hasIf && hasErrReturn { - parts = append(parts, "Linear with early error returns") - } else if hasIf { - parts = append(parts, "Conditional branching") - } else { - parts = append(parts, "Linear") - } - - if hasDefer { - parts = append(parts, "with deferred cleanup") - } - if hasGo { - parts = append(parts, "with concurrent goroutines") - } - if hasGoto { - parts = append(parts, "with goto jumps") - } - _ = hasRecursion - - return strings.Join(parts, " ") -} - -func DetectSideEffects(funcBody string) []string { - var effects []string - - filePatterns := []string{ - `os\.Open`, `os\.Create`, `os\.Remove`, `os\.Mkdir`, - `os\.ReadFile`, `os\.WriteFile`, `os\.Stat`, - `ioutil\.ReadFile`, `ioutil\.WriteFile`, `ioutil\.TempFile`, - `filepath\.Walk`, - } - for _, p := range filePatterns { - if regexp.MustCompile(p).MatchString(funcBody) { - effects = append(effects, "File I/O") - break - } - } - - netPatterns := []string{ - `http\.Get`, `http\.Post`, `http\.Do`, - `net\.Dial`, `net\.Listen`, - `\.Do\(req`, `client\.`, - `grpc\.`, `websocket\.`, - } - for _, p := range netPatterns { - if regexp.MustCompile(p).MatchString(funcBody) { - effects = append(effects, "Network calls") - break - } - } - - if regexp.MustCompile(`\bgo\s+\w`).MatchString(funcBody) { - effects = append(effects, "Goroutine spawning") - } - - if regexp.MustCompile(`\.Lock\(\)|\.RLock\(\)`).MatchString(funcBody) { - effects = append(effects, "Mutex locking") - } - - if regexp.MustCompile(`<-\s*\w|(\w+)\s*<-`).MatchString(funcBody) { - effects = append(effects, "Channel communication") - } - - if regexp.MustCompile(`\b(os\.Setenv|os\.Exit|log\.Fatal)`).MatchString(funcBody) { - effects = append(effects, "Process-level side effects") - } - - dbPatterns := []string{ - `\.Exec\(`, `\.Query\(`, `\.QueryRow\(`, - `\.Begin\(`, `\.Commit\(`, `\.Rollback\(`, - } - for _, p := range dbPatterns { - if regexp.MustCompile(p).MatchString(funcBody) { - effects = append(effects, "Database operations") - break - } - } - - if regexp.MustCompile(`fmt\.Print|fmt\.Fprint|os\.Stdout|os\.Stderr`).MatchString(funcBody) { - effects = append(effects, "Standard output") - } - - return effects -} - -func FormatExplanation(exp *CodeExplanation) string { - var sb strings.Builder - - sb.WriteString(fmt.Sprintf("## %s\n\n", exp.Symbol)) - - for _, section := range exp.Sections { - switch section.Title { - case "Parameters": - sb.WriteString(fmt.Sprintf("**%s:**\n%s\n\n", section.Title, section.Content)) - case "Returns": - sb.WriteString(fmt.Sprintf("**%s:** %s\n\n", section.Title, section.Content)) - default: - sb.WriteString(fmt.Sprintf("**%s:** %s\n\n", section.Title, section.Content)) - } - } - - if exp.Complexity != "" { - sb.WriteString(fmt.Sprintf("**Complexity:** %s\n", exp.Complexity)) - } - - return sb.String() -} diff --git a/internal/engine/code/code_explainer_helpers.go b/internal/engine/code/code_explainer_helpers.go deleted file mode 100644 index 7c92fdf61..000000000 --- a/internal/engine/code/code_explainer_helpers.go +++ /dev/null @@ -1,440 +0,0 @@ -package code - -import ( - "fmt" - "go/ast" - "go/token" - "regexp" - "strings" -) - -// This file holds the internal helpers for the CodeExplainer: AST extraction, -// purpose inference, complexity/error-handling analysis, and pattern detection. -// The CodeExplainer type and its public Explain*/Infer/Describe/Detect/Format -// entry points live in code_explainer.go. - -func explainerExtractParams(fd *ast.FuncDecl) [][2]string { - var params [][2]string - if fd.Type.Params == nil { - return params - } - for _, field := range fd.Type.Params.List { - typeStr := explainerExprToString(field.Type) - if len(field.Names) == 0 { - params = append(params, [2]string{"", typeStr}) - } - for _, name := range field.Names { - params = append(params, [2]string{name.Name, typeStr}) - } - } - return params -} - -func extractReturns(fd *ast.FuncDecl) []string { - var returns []string - if fd.Type.Results == nil { - return returns - } - for _, field := range fd.Type.Results.List { - typeStr := explainerExprToString(field.Type) - if len(field.Names) > 0 { - for range field.Names { - returns = append(returns, typeStr) - } - } else { - returns = append(returns, typeStr) - } - } - return returns -} - -func explainerExtractDocComment(fd *ast.FuncDecl) string { - if fd.Doc == nil { - return "" - } - text := strings.TrimSpace(fd.Doc.Text()) - if strings.HasPrefix(text, fd.Name.Name+" ") { - text = text[len(fd.Name.Name)+1:] - } - if idx := strings.Index(text, ". "); idx > 0 { - return text[:idx+1] - } - return text -} - -func extractFuncBody(content string, fd *ast.FuncDecl, fset *token.FileSet) string { - if fd.Body == nil { - return "" - } - start := fset.Position(fd.Body.Lbrace).Offset - end := fset.Position(fd.Body.Rbrace).Offset - if start >= 0 && end > start && end <= len(content) { - return content[start:end] - } - return "" -} - -func explainerExprToString(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.SelectorExpr: - return explainerExprToString(e.X) + "." + e.Sel.Name - case *ast.StarExpr: - return "*" + explainerExprToString(e.X) - case *ast.ArrayType: - return "[]" + explainerExprToString(e.Elt) - case *ast.MapType: - return "map[" + explainerExprToString(e.Key) + "]" + explainerExprToString(e.Value) - case *ast.InterfaceType: - return "interface{}" - case *ast.FuncType: - return "func(...)" - case *ast.ChanType: - return "chan " + explainerExprToString(e.Value) - case *ast.Ellipsis: - return "..." + explainerExprToString(e.Elt) - default: - return "unknown" - } -} - -func inferParamPurpose(name, typeName string) string { - lower := strings.ToLower(name) - switch { - case lower == "ctx" || typeName == "context.Context": - return "Context for cancellation and deadlines" - case lower == "err" || typeName == "error": - return "Error to handle" - case strings.Contains(lower, "path") || strings.Contains(lower, "file"): - return "File path" - case strings.Contains(lower, "name"): - return "Name identifier" - case strings.Contains(lower, "id"): - return "Unique identifier" - case strings.Contains(lower, "timeout") || strings.Contains(lower, "duration"): - return "Time duration" - case strings.Contains(lower, "config") || strings.Contains(lower, "opts"): - return "Configuration options" - case strings.Contains(lower, "fn") || strings.Contains(lower, "func") || strings.Contains(lower, "callback"): - return "Callback function" - case strings.Contains(lower, "buf") || strings.Contains(lower, "data") || strings.Contains(lower, "bytes"): - return "Raw data buffer" - case strings.Contains(lower, "url") || strings.Contains(lower, "addr"): - return "Network address" - case strings.Contains(lower, "token"): - return "Authentication or parsing token" - case strings.Contains(lower, "key"): - return "Lookup key" - case strings.Contains(lower, "val") || strings.Contains(lower, "value"): - return "Value to process" - case typeName == "string": - return fmt.Sprintf("The %s string", name) - case typeName == "int" || typeName == "int64": - return fmt.Sprintf("The %s count or index", name) - case typeName == "bool": - return fmt.Sprintf("Whether to enable %s", name) - default: - return fmt.Sprintf("The %s to use", name) - } -} - -func inferTypePurpose(name string) string { - words := splitCamelCase(name) - if len(words) == 0 { - return "A type" - } - last := strings.ToLower(words[len(words)-1]) - prefix := "" - if len(words) > 1 { - prefix = strings.Join(words[:len(words)-1], " ") - } - - switch last { - case "config", "options", "opts", "settings": - return fmt.Sprintf("Configuration for %s", lowerFirst(prefix)) - case "handler": - return fmt.Sprintf("Handles %s operations", lowerFirst(prefix)) - case "service", "server": - return fmt.Sprintf("Provides %s functionality", lowerFirst(prefix)) - case "client": - return fmt.Sprintf("Client for communicating with %s", lowerFirst(prefix)) - case "store", "repository", "repo": - return fmt.Sprintf("Persistent storage for %s", lowerFirst(prefix)) - case "manager": - return fmt.Sprintf("Manages lifecycle of %s", lowerFirst(prefix)) - case "builder": - return fmt.Sprintf("Builder pattern for constructing %s", lowerFirst(prefix)) - case "error", "err": - return fmt.Sprintf("Error type for %s failures", lowerFirst(prefix)) - case "result": - return fmt.Sprintf("Result of %s operation", lowerFirst(prefix)) - case "request", "req": - return fmt.Sprintf("Request payload for %s", lowerFirst(prefix)) - case "response", "resp": - return fmt.Sprintf("Response from %s", lowerFirst(prefix)) - default: - return fmt.Sprintf("Represents %s", lowerFirst(strings.Join(words, " "))) - } -} - -func inferFieldPurpose(name, typeName string) string { - lower := strings.ToLower(name) - switch { - case lower == "mu" || strings.Contains(typeName, "Mutex"): - return "Protects concurrent access" - case lower == "ctx": - return "Context for cancellation" - case lower == "id": - return "Unique identifier" - case strings.Contains(lower, "err"): - return "Last error encountered" - case strings.Contains(lower, "done") || strings.Contains(lower, "closed"): - return "Signals completion or shutdown" - case strings.Contains(lower, "count") || strings.Contains(lower, "num"): - return "Counter value" - case strings.Contains(lower, "max") || strings.Contains(lower, "limit"): - return "Upper bound constraint" - case strings.Contains(lower, "min"): - return "Lower bound constraint" - case strings.Contains(lower, "timeout"): - return "Maximum wait duration" - case strings.Contains(lower, "name"): - return "Human-readable name" - case strings.Contains(lower, "path") || strings.Contains(lower, "dir"): - return "Filesystem path" - default: - return fmt.Sprintf("The %s value", name) - } -} - -func splitCamelCase(s string) []string { - var words []string - current := strings.Builder{} - for i, r := range s { - if i > 0 && r >= 'A' && r <= 'Z' { - if current.Len() > 0 { - words = append(words, current.String()) - current.Reset() - } - } - current.WriteRune(r) - } - if current.Len() > 0 { - words = append(words, current.String()) - } - return words -} - -func lowerFirst(s string) string { - if s == "" { - return s - } - return strings.ToLower(s[:1]) + s[1:] -} - -func containsType(types []string, target string) bool { - for _, t := range types { - if t == target || strings.Contains(t, target) { - return true - } - } - return false -} - -func computeCyclomaticComplexity(body string) int { - cc := 1 - patterns := []string{ - `\bif\b`, `\belse if\b`, `\bfor\b`, `\bcase\b`, - `&&`, `\|\|`, `\bselect\b`, - } - for _, p := range patterns { - re := regexp.MustCompile(p) - cc += len(re.FindAllString(body, -1)) - } - return cc -} - -func classifyComplexity(cc int) string { - switch { - case cc <= 5: - return "Low" - case cc <= 10: - return "Moderate" - case cc <= 20: - return "High" - default: - return "Very High" - } -} - -func describeErrorHandling(body string) string { - hasErrCheck := regexp.MustCompile(`if\s+err\s*!=\s*nil`).MatchString(body) - hasWrap := regexp.MustCompile(`fmt\.Errorf\(.*%w`).MatchString(body) - hasPanic := regexp.MustCompile(`\bpanic\(`).MatchString(body) - hasRecover := regexp.MustCompile(`\brecover\(\)`).MatchString(body) - - if !hasErrCheck && !hasPanic { - return "No explicit error handling" - } - - var parts []string - if hasErrCheck && hasWrap { - parts = append(parts, "Returns wrapped errors with context") - } else if hasErrCheck { - parts = append(parts, "Checks and propagates errors") - } - if hasPanic { - parts = append(parts, "May panic on unrecoverable errors") - } - if hasRecover { - parts = append(parts, "Includes panic recovery") - } - - if len(parts) == 0 { - return "Basic error checking" - } - return strings.Join(parts, "; ") -} - -func extractDependencies(body string) []string { - var deps []string - re := regexp.MustCompile(`\b([a-z][a-z0-9]+)\.\w+`) - matches := re.FindAllStringSubmatch(body, -1) - seen := map[string]bool{} - for _, m := range matches { - pkg := m[1] - if !seen[pkg] && pkg != "err" && pkg != "nil" { - seen[pkg] = true - deps = append(deps, pkg) - } - } - return deps -} - -func funcSignature(fd *ast.FuncDecl) string { - var sb strings.Builder - sb.WriteString(fd.Name.Name) - sb.WriteString("(") - if fd.Type.Params != nil { - var params []string - for _, field := range fd.Type.Params.List { - typeStr := explainerExprToString(field.Type) - if len(field.Names) > 0 { - for _, name := range field.Names { - params = append(params, name.Name+" "+typeStr) - } - } else { - params = append(params, typeStr) - } - } - sb.WriteString(strings.Join(params, ", ")) - } - sb.WriteString(")") - if fd.Type.Results != nil && len(fd.Type.Results.List) > 0 { - var results []string - for _, field := range fd.Type.Results.List { - results = append(results, explainerExprToString(field.Type)) - } - if len(results) == 1 { - sb.WriteString(" " + results[0]) - } else { - sb.WriteString(" (" + strings.Join(results, ", ") + ")") - } - } - return sb.String() -} - -func findConstructor(f *ast.File, typeName string) string { - candidates := []string{ - "New" + typeName, - "new" + typeName, - } - for _, decl := range f.Decls { - fd, ok := decl.(*ast.FuncDecl) - if !ok || fd.Recv != nil { - continue - } - for _, c := range candidates { - if fd.Name.Name == c { - return c - } - } - } - return "" -} - -func detectImplementedInterfaces(f *ast.File, typeName string, methods []string) []string { - var ifaces []string - methodSet := map[string]bool{} - for _, m := range methods { - methodSet[m] = true - } - - if methodSet["String"] { - ifaces = append(ifaces, "fmt.Stringer") - } - if methodSet["Error"] { - ifaces = append(ifaces, "error") - } - if methodSet["Read"] { - ifaces = append(ifaces, "io.Reader") - } - if methodSet["Write"] { - ifaces = append(ifaces, "io.Writer") - } - if methodSet["Close"] { - ifaces = append(ifaces, "io.Closer") - } - if methodSet["ServeHTTP"] { - ifaces = append(ifaces, "http.Handler") - } - if methodSet["MarshalJSON"] { - ifaces = append(ifaces, "json.Marshaler") - } - if methodSet["UnmarshalJSON"] { - ifaces = append(ifaces, "json.Unmarshaler") - } - if methodSet["Len"] && methodSet["Less"] && methodSet["Swap"] { - ifaces = append(ifaces, "sort.Interface") - } - - return ifaces -} - -func detectPatterns(content string) []string { - var patterns []string - - if regexp.MustCompile(`sync\.(Mutex|RWMutex)`).MatchString(content) { - patterns = append(patterns, "- Mutex-based concurrency control") - } - if regexp.MustCompile(`sync\.Once`).MatchString(content) { - patterns = append(patterns, "- Singleton/once initialization") - } - if regexp.MustCompile(`chan\s+\w`).MatchString(content) { - patterns = append(patterns, "- Channel-based communication") - } - if regexp.MustCompile(`context\.Context`).MatchString(content) { - patterns = append(patterns, "- Context propagation for cancellation") - } - if regexp.MustCompile(`interface\s*\{`).MatchString(content) { - patterns = append(patterns, "- Interface-based abstraction") - } - if regexp.MustCompile(`func\s+New\w+\(`).MatchString(content) { - patterns = append(patterns, "- Constructor functions (New* pattern)") - } - if regexp.MustCompile(`defer\s+`).MatchString(content) { - patterns = append(patterns, "- Deferred cleanup") - } - if regexp.MustCompile(`fmt\.Errorf\(.*%w`).MatchString(content) { - patterns = append(patterns, "- Error wrapping with context") - } - if regexp.MustCompile(`select\s*\{`).MatchString(content) { - patterns = append(patterns, "- Select-based multiplexing") - } - if regexp.MustCompile(`type\s+\w+\s+struct\s*\{[^}]*\w+\s+interface`).MatchString(content) { - patterns = append(patterns, "- Dependency injection via interfaces") - } - - return patterns -} diff --git a/internal/engine/code/code_explainer_test.go b/internal/engine/code/code_explainer_test.go deleted file mode 100644 index 687aedff7..000000000 --- a/internal/engine/code/code_explainer_test.go +++ /dev/null @@ -1,811 +0,0 @@ -package code - -import ( - "strings" - "testing" -) - -func TestNewCodeExplainer(t *testing.T) { - ce := NewCodeExplainer() - if ce == nil { - t.Fatal("NewCodeExplainer returned nil") - } -} - -func TestExplainFunction_Basic(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -import "fmt" - -// ValidateToken checks whether a JWT token is valid and returns the parsed claims. -func ValidateToken(token string) (*Claims, error) { - if token == "" { - return nil, fmt.Errorf("empty token: %w", ErrInvalid) - } - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("malformed token: %w", ErrInvalid) - } - claims, err := parseClaims(parts[1]) - if err != nil { - return nil, fmt.Errorf("parse claims: %w", err) - } - return claims, nil -} -` - exp, err := ce.ExplainFunction("token.go", src, "ValidateToken") - if err != nil { - t.Fatalf("ExplainFunction failed: %v", err) - } - - if exp.File != "token.go" { - t.Errorf("expected file token.go, got %s", exp.File) - } - if exp.Symbol != "ValidateToken" { - t.Errorf("expected symbol ValidateToken, got %s", exp.Symbol) - } - if exp.Summary == "" { - t.Error("expected non-empty summary") - } - if len(exp.Sections) == 0 { - t.Error("expected non-empty sections") - } - - // Check for expected sections - sectionTitles := make(map[string]bool) - for _, s := range exp.Sections { - sectionTitles[s.Title] = true - } - for _, expected := range []string{"Purpose", "Parameters", "Returns", "Control Flow", "Error Handling", "Side Effects"} { - if !sectionTitles[expected] { - t.Errorf("missing section %q", expected) - } - } - - // Check complexity - if !strings.Contains(exp.Complexity, "CC:") { - t.Errorf("expected complexity with CC, got %q", exp.Complexity) - } -} - -func TestExplainFunction_NotFound(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -func Foo() {} -` - _, err := ce.ExplainFunction("test.go", src, "Bar") - if err == nil { - t.Fatal("expected error for missing function") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("expected 'not found' in error, got %q", err.Error()) - } -} - -func TestExplainFunction_ParseError(t *testing.T) { - ce := NewCodeExplainer() - _, err := ce.ExplainFunction("bad.go", "not valid go code {{{{", "Foo") - if err == nil { - t.Fatal("expected parse error") - } -} - -func TestExplainFunction_WithSideEffects(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -import ( - "os" - "net/http" -) - -func FetchAndSave(url, path string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} -` - exp, err := ce.ExplainFunction("io.go", src, "FetchAndSave") - if err != nil { - t.Fatalf("ExplainFunction failed: %v", err) - } - - // Find side effects section - var sideEffects string - for _, s := range exp.Sections { - if s.Title == "Side Effects" { - sideEffects = s.Content - break - } - } - if sideEffects == "None (pure function)" { - t.Error("expected side effects to be detected") - } - if !strings.Contains(sideEffects, "Network") && !strings.Contains(sideEffects, "File") { - t.Errorf("expected network or file side effects, got %q", sideEffects) - } -} - -func TestExplainType_Struct(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -import "sync" - -// TokenCache stores validated tokens for quick lookup. -type TokenCache struct { - mu sync.RWMutex - tokens map[string]*Token - maxSize int -} - -func NewTokenCache(maxSize int) *TokenCache { - return &TokenCache{ - tokens: make(map[string]*Token), - maxSize: maxSize, - } -} - -func (tc *TokenCache) Get(key string) (*Token, bool) { - tc.mu.RLock() - defer tc.mu.RUnlock() - t, ok := tc.tokens[key] - return t, ok -} - -func (tc *TokenCache) Set(key string, token *Token) { - tc.mu.Lock() - defer tc.mu.Unlock() - tc.tokens[key] = token -} -` - exp, err := ce.ExplainType("cache.go", src, "TokenCache") - if err != nil { - t.Fatalf("ExplainType failed: %v", err) - } - - if exp.Symbol != "TokenCache" { - t.Errorf("expected symbol TokenCache, got %s", exp.Symbol) - } - if exp.Summary == "" { - t.Error("expected non-empty summary") - } - - sectionTitles := make(map[string]bool) - for _, s := range exp.Sections { - sectionTitles[s.Title] = true - } - - if !sectionTitles["Purpose"] { - t.Error("missing Purpose section") - } - if !sectionTitles["Fields"] { - t.Error("missing Fields section") - } - if !sectionTitles["Methods"] { - t.Error("missing Methods section") - } - if !sectionTitles["Constructor"] { - t.Error("missing Constructor section") - } -} - -func TestExplainType_Interface(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -// Validator checks whether a value meets certain criteria. -type Validator interface { - Validate(value interface{}) error - Name() string -} -` - exp, err := ce.ExplainType("iface.go", src, "Validator") - if err != nil { - t.Fatalf("ExplainType failed: %v", err) - } - - if exp.Symbol != "Validator" { - t.Errorf("expected symbol Validator, got %s", exp.Symbol) - } - - var methodsSection string - for _, s := range exp.Sections { - if s.Title == "Methods" { - methodsSection = s.Content - break - } - } - if !strings.Contains(methodsSection, "Validate") { - t.Errorf("expected Validate in methods, got %q", methodsSection) - } - if !strings.Contains(methodsSection, "Name") { - t.Errorf("expected Name in methods, got %q", methodsSection) - } -} - -func TestExplainType_NotFound(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -type Foo struct{} -` - _, err := ce.ExplainType("test.go", src, "Bar") - if err == nil { - t.Fatal("expected error for missing type") - } -} - -func TestExplainFile(t *testing.T) { - ce := NewCodeExplainer() - src := `// Package auth provides JWT token validation and session management. -package auth - -import ( - "context" - "sync" - "time" -) - -type TokenManager struct { - mu sync.Mutex - store TokenStore - timeout time.Duration -} - -type TokenStore interface { - Get(ctx context.Context, id string) (*Token, error) - Set(ctx context.Context, token *Token) error -} - -func NewTokenManager(store TokenStore, timeout time.Duration) *TokenManager { - return &TokenManager{store: store, timeout: timeout} -} - -func (tm *TokenManager) Validate(ctx context.Context, raw string) (*Claims, error) { - return nil, nil -} - -func parseHeader(raw string) (Header, error) { - return Header{}, nil -} -` - exp, err := ce.ExplainFile("auth.go", src) - if err != nil { - t.Fatalf("ExplainFile failed: %v", err) - } - - if exp.Symbol != "auth" { - t.Errorf("expected symbol auth, got %s", exp.Symbol) - } - - sectionTitles := make(map[string]bool) - for _, s := range exp.Sections { - sectionTitles[s.Title] = true - } - - if !sectionTitles["Package Purpose"] { - t.Error("missing Package Purpose section") - } - if !sectionTitles["Exported API"] { - t.Error("missing Exported API section") - } - if !sectionTitles["Internal Structure"] { - t.Error("missing Internal Structure section") - } - if !sectionTitles["Key Patterns"] { - t.Error("missing Key Patterns section") - } - - // Verify exported items found - var apiSection string - for _, s := range exp.Sections { - if s.Title == "Exported API" { - apiSection = s.Content - break - } - } - if !strings.Contains(apiSection, "TokenManager") { - t.Errorf("expected TokenManager in API section, got %q", apiSection) - } - if !strings.Contains(apiSection, "NewTokenManager") { - t.Errorf("expected NewTokenManager in API section, got %q", apiSection) - } -} - -func TestInferPurpose(t *testing.T) { - tests := []struct { - name string - params []string - returns []string - contains string - }{ - { - name: "NewClient", - params: []string{"string", "int"}, - returns: []string{"*Client"}, - contains: "Creates a new", - }, - { - name: "GetUser", - params: []string{"string"}, - returns: []string{"*User", "error"}, - contains: "Retrieves", - }, - { - name: "ValidateToken", - params: []string{"string"}, - returns: []string{"*Claims", "error"}, - contains: "Validates", - }, - { - name: "IsActive", - params: []string{}, - returns: []string{"bool"}, - contains: "Returns whether", - }, - { - name: "DeleteSession", - params: []string{"string"}, - returns: []string{"error"}, - contains: "Removes", - }, - { - name: "ParseConfig", - params: []string{"[]byte"}, - returns: []string{"*Config", "error"}, - contains: "Parses", - }, - { - name: "SetTimeout", - params: []string{"time.Duration"}, - returns: []string{}, - contains: "Updates", - }, - { - name: "Close", - params: []string{}, - returns: []string{"error"}, - contains: "Shuts down", - }, - { - name: "StartWorker", - params: []string{"context.Context"}, - returns: []string{"error"}, - contains: "Starts", - }, - { - name: "FindMatches", - params: []string{"string"}, - returns: []string{"[]Match"}, - contains: "Searches", - }, - { - name: "FormatOutput", - params: []string{"*Result"}, - returns: []string{"string"}, - contains: "Formats", - }, - { - name: "HandleRequest", - params: []string{"*http.Request"}, - returns: []string{"*http.Response", "error"}, - contains: "Handles", - }, - { - name: "WriteConfig", - params: []string{"*Config", "string"}, - returns: []string{"error"}, - contains: "Persists", - }, - { - name: "InitDatabase", - params: []string{"string"}, - returns: []string{"error"}, - contains: "Initializes", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := InferPurpose(tt.name, tt.params, tt.returns) - if !strings.Contains(result, tt.contains) { - t.Errorf("InferPurpose(%q) = %q, want to contain %q", tt.name, result, tt.contains) - } - }) - } -} - -func TestDescribeControlFlow(t *testing.T) { - tests := []struct { - name string - body string - contains string - }{ - { - name: "linear with error returns", - body: `if err != nil { return nil, err }`, - contains: "early error returns", - }, - { - name: "loop with break", - body: `for i := 0; i < n; i++ { if done { break } }`, - contains: "Loop with conditional break", - }, - { - name: "range iteration", - body: `for _, v := range items { process(v) }`, - contains: "Range iteration", - }, - { - name: "switch dispatch", - body: `switch kind { case "a": doA() case "b": doB() }`, - contains: "Switch dispatch", - }, - { - name: "select", - body: `select { case <-ctx.Done(): return case msg := <-ch: handle(msg) }`, - contains: "Channel select", - }, - { - name: "with defer", - body: `defer f.Close() if err != nil { return err }`, - contains: "deferred cleanup", - }, - { - name: "with goroutine", - body: `go worker(ctx)`, - contains: "concurrent goroutines", - }, - { - name: "range with continue", - body: `for _, v := range items { if v == nil { continue } }`, - contains: "conditional skip", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := DescribeControlFlow(tt.body) - if !strings.Contains(result, tt.contains) { - t.Errorf("DescribeControlFlow(%q) = %q, want to contain %q", tt.name, result, tt.contains) - } - }) - } -} - -func TestDetectSideEffects(t *testing.T) { - tests := []struct { - name string - body string - contains string - }{ - { - name: "file IO", - body: `os.ReadFile(path)`, - contains: "File I/O", - }, - { - name: "network", - body: `http.Get(url)`, - contains: "Network calls", - }, - { - name: "goroutine", - body: `go worker(ctx)`, - contains: "Goroutine spawning", - }, - { - name: "mutex", - body: `m.Lock()`, - contains: "Mutex locking", - }, - { - name: "channel", - body: `ch <- msg`, - contains: "Channel communication", - }, - { - name: "database", - body: `db.Exec("INSERT INTO ...")`, - contains: "Database operations", - }, - { - name: "stdout", - body: `fmt.Println("hello")`, - contains: "Standard output", - }, - { - name: "pure function", - body: `return a + b`, - contains: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := DetectSideEffects(tt.body) - if tt.contains == "" { - if len(result) != 0 { - t.Errorf("expected no side effects for %q, got %v", tt.name, result) - } - return - } - found := false - for _, r := range result { - if strings.Contains(r, tt.contains) { - found = true - break - } - } - if !found { - t.Errorf("DetectSideEffects(%q) = %v, want to contain %q", tt.name, result, tt.contains) - } - }) - } -} - -func TestFormatExplanation(t *testing.T) { - exp := &CodeExplanation{ - Symbol: "ValidateToken", - Summary: "Validates a JWT token", - Sections: []ExplanationSection{ - {Title: "Purpose", Content: "Validates a JWT token and returns the parsed claims."}, - {Title: "Parameters", Content: "- `token string` — The raw JWT token string to validate"}, - {Title: "Returns", Content: "`*Claims, error`"}, - {Title: "Control Flow", Content: "Linear with early error returns"}, - {Title: "Error Handling", Content: "Returns wrapped errors with context"}, - {Title: "Side Effects", Content: "None (pure function)"}, - }, - Complexity: "Low (CC: 4)", - } - - result := FormatExplanation(exp) - - if !strings.Contains(result, "## ValidateToken") { - t.Error("missing header") - } - if !strings.Contains(result, "**Purpose:**") { - t.Error("missing Purpose section") - } - if !strings.Contains(result, "**Parameters:**") { - t.Error("missing Parameters section") - } - if !strings.Contains(result, "**Returns:**") { - t.Error("missing Returns section") - } - if !strings.Contains(result, "**Control Flow:**") { - t.Error("missing Control Flow section") - } - if !strings.Contains(result, "**Error Handling:**") { - t.Error("missing Error Handling section") - } - if !strings.Contains(result, "**Side Effects:**") { - t.Error("missing Side Effects section") - } - if !strings.Contains(result, "**Complexity:** Low (CC: 4)") { - t.Error("missing Complexity line") - } -} - -func TestExplainFunction_NoParams(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -func Close() error { - return nil -} -` - exp, err := ce.ExplainFunction("close.go", src, "Close") - if err != nil { - t.Fatalf("ExplainFunction failed: %v", err) - } - - // Should not have Parameters section - for _, s := range exp.Sections { - if s.Title == "Parameters" { - t.Error("unexpected Parameters section for no-param function") - } - } -} - -func TestExplainFunction_DocComment(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -// ProcessBatch handles a batch of items concurrently. -func ProcessBatch(items []string) error { - for _, item := range items { - go handle(item) - } - return nil -} -` - exp, err := ce.ExplainFunction("batch.go", src, "ProcessBatch") - if err != nil { - t.Fatalf("ExplainFunction failed: %v", err) - } - - // Summary should come from doc comment - if !strings.Contains(exp.Summary, "handles a batch") { - t.Errorf("expected doc comment in summary, got %q", exp.Summary) - } -} - -func TestExplainType_WithStringerInterface(t *testing.T) { - ce := NewCodeExplainer() - src := `package example - -type Status struct { - Code int - Message string -} - -func (s *Status) String() string { - return s.Message -} - -func (s *Status) Error() string { - return s.Message -} -` - exp, err := ce.ExplainType("status.go", src, "Status") - if err != nil { - t.Fatalf("ExplainType failed: %v", err) - } - - var implSection string - for _, s := range exp.Sections { - if s.Title == "Implements" { - implSection = s.Content - break - } - } - if !strings.Contains(implSection, "fmt.Stringer") { - t.Errorf("expected fmt.Stringer in implements, got %q", implSection) - } - if !strings.Contains(implSection, "error") { - t.Errorf("expected error in implements, got %q", implSection) - } -} - -func TestSplitCamelCase(t *testing.T) { - tests := []struct { - input string - expected []string - }{ - {"ValidateToken", []string{"Validate", "Token"}}, - {"NewHTTPClient", []string{"New", "H", "T", "T", "P", "Client"}}, - {"getUser", []string{"get", "User"}}, - {"ID", []string{"I", "D"}}, - {"simple", []string{"simple"}}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := splitCamelCase(tt.input) - if len(result) != len(tt.expected) { - t.Errorf("splitCamelCase(%q) = %v, want %v", tt.input, result, tt.expected) - return - } - for i := range result { - if result[i] != tt.expected[i] { - t.Errorf("splitCamelCase(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expected[i]) - } - } - }) - } -} - -func TestComputeCyclomaticComplexity(t *testing.T) { - tests := []struct { - name string - body string - min int - max int - }{ - { - name: "simple", - body: "return nil", - min: 1, - max: 1, - }, - { - name: "one if", - body: "if x > 0 { return x }", - min: 2, - max: 2, - }, - { - name: "if with and", - body: "if x > 0 && y > 0 { return x }", - min: 3, - max: 3, - }, - { - name: "loop with ifs", - body: "for _, v := range items { if v > 0 { count++ } else if v < 0 { neg++ } }", - min: 4, - max: 5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cc := computeCyclomaticComplexity(tt.body) - if cc < tt.min || cc > tt.max { - t.Errorf("computeCyclomaticComplexity = %d, want between %d and %d", cc, tt.min, tt.max) - } - }) - } -} - -func TestExplainFile_ParseError(t *testing.T) { - ce := NewCodeExplainer() - _, err := ce.ExplainFile("bad.go", "this is not valid go {{{{") - if err == nil { - t.Fatal("expected parse error") - } -} - -func TestExplainerDetectPatterns(t *testing.T) { - src := `package example - -import ( - "context" - "sync" - "fmt" -) - -type Service struct { - mu sync.Mutex - once sync.Once - ch chan string -} - -func NewService() *Service { - return &Service{ch: make(chan string)} -} - -func (s *Service) Run(ctx context.Context) error { - defer s.cleanup() - select { - case <-ctx.Done(): - return fmt.Errorf("cancelled: %w", ctx.Err()) - case msg := <-s.ch: - _ = msg - } - return nil -} -` - patterns := detectPatterns(src) - if len(patterns) == 0 { - t.Fatal("expected patterns to be detected") - } - - joined := strings.Join(patterns, "\n") - checks := []string{ - "Mutex-based concurrency", - "Singleton/once initialization", - "Channel-based communication", - "Context propagation", - "Constructor functions", - "Deferred cleanup", - "Error wrapping", - "Select-based multiplexing", - } - for _, check := range checks { - if !strings.Contains(joined, check) { - t.Errorf("expected pattern %q to be detected", check) - } - } -} diff --git a/internal/engine/code/code_lens.go b/internal/engine/code/code_lens.go deleted file mode 100644 index 27a2f9c12..000000000 --- a/internal/engine/code/code_lens.go +++ /dev/null @@ -1,539 +0,0 @@ -package code - -import ( - "context" - "errors" - "fmt" - "os/exec" - "regexp" - "sort" - "strings" - "sync" - "time" - "unicode" -) - -type CodeLens struct { - File string - Line int - Label string - Category string - Command string - Tooltip string -} - -type LensGenerator func(file, content string) []CodeLens - -type CodeLensProvider struct { - Providers map[string]LensGenerator - mu sync.RWMutex -} - -func NewCodeLensProvider() *CodeLensProvider { - p := &CodeLensProvider{ - Providers: make(map[string]LensGenerator), - } - p.Providers["test_status"] = GenerateTestLens - p.Providers["complexity"] = GenerateComplexityLens - p.Providers["references"] = GenerateReferenceLens - p.Providers["age"] = GenerateAgeLens - p.Providers["coverage"] = GenerateCoverageLens - return p -} - -func (p *CodeLensProvider) Register(name string, generator LensGenerator) { - p.mu.Lock() - defer p.mu.Unlock() - p.Providers[name] = generator -} - -func (p *CodeLensProvider) Generate(file, content string) []CodeLens { - p.mu.RLock() - defer p.mu.RUnlock() - - var all []CodeLens - for _, gen := range p.Providers { - lenses := gen(file, content) - all = append(all, lenses...) - } - sort.Slice(all, func(i, j int) bool { - if all[i].Line != all[j].Line { - return all[i].Line < all[j].Line - } - return all[i].Category < all[j].Category - }) - return all -} - -func FilterByCategory(lenses []CodeLens, category string) []CodeLens { - var result []CodeLens - for _, l := range lenses { - if l.Category == category { - result = append(result, l) - } - } - return result -} - -func FormatLenses(file string, lenses []CodeLens) string { - if len(lenses) == 0 { - return fmt.Sprintf("Code Lenses for %s:\n (none)", file) - } - var b strings.Builder - b.WriteString(fmt.Sprintf("Code Lenses for %s:\n", file)) - for _, l := range lenses { - b.WriteString(fmt.Sprintf("L%-3d [%s] %s\n", l.Line, l.Label, l.Tooltip)) - } - return strings.TrimRight(b.String(), "\n") -} - -var testFuncRe = regexp.MustCompile(`(?m)^func\s+(Test\w+)\s*\(`) - -func GenerateTestLens(file, content string) []CodeLens { - if !strings.HasSuffix(file, "_test.go") { - return nil - } - - var lenses []CodeLens - lines := strings.Split(content, "\n") - for i, line := range lines { - matches := testFuncRe.FindStringSubmatch(line) - if matches == nil { - continue - } - funcName := matches[1] - status := lookupTestStatus(file, funcName) - label := fmt.Sprintf("test: %s", status) - tooltip := fmt.Sprintf("func %s", funcName) - if status == "PASS" { - tooltip += " — passed" - } else if status == "FAIL" { - tooltip += " — failed" - } else { - tooltip += " — status unknown" - } - lenses = append(lenses, CodeLens{ - File: file, - Line: i + 1, - Label: label, - Category: "test_status", - Command: fmt.Sprintf("go test -run ^%s$ %s", funcName, file), - Tooltip: tooltip, - }) - } - return lenses -} - -func lookupTestStatus(file, funcName string) string { - dir := file - if idx := strings.LastIndex(file, "/"); idx >= 0 { - dir = file[:idx] - } - cmd := exec.CommandContext(context.Background(), "go", "test", "-run", "^"+funcName+"$", "-count=1", "-timeout=10s", dir) // #nosec G204 -- invocation of well-known dev tool with internally-derived args (file paths / package names from tool params) - err := cmd.Run() - if err == nil { - return "PASS" - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - if exitErr.ExitCode() == 1 { - return "FAIL" - } - } - return "UNKNOWN" -} - -var funcDeclRe = regexp.MustCompile(`(?m)^func\s+(?:\(\s*\w+\s+\*?\w+\s*\)\s+)?(\w+)\s*\(`) - -func GenerateComplexityLens(file, content string) []CodeLens { - const threshold = 5 - var lenses []CodeLens - - functions := extractFunctions(content) - for _, fn := range functions { - cc := calculateCyclomaticComplexity(fn.body) - if cc > threshold { - label := fmt.Sprintf("complexity: %d", cc) - tooltip := fmt.Sprintf("func %s — consider splitting", fn.name) - lenses = append(lenses, CodeLens{ - File: file, - Line: fn.line, - Label: label, - Category: "complexity", - Command: "", - Tooltip: tooltip, - }) - } - } - return lenses -} - -func GenerateReferenceLens(file, content string) []CodeLens { - var lenses []CodeLens - - lines := strings.Split(content, "\n") - symbols := extractExportedSymbols(content) - - for _, sym := range symbols { - count := countReferences(content, sym.name) - if count > 0 { - label := fmt.Sprintf("references: %d", count) - tooltip := fmt.Sprintf("func %s — ", sym.name) - if count >= 5 { - tooltip += "widely used" - } else { - tooltip += fmt.Sprintf("referenced %d times", count) - } - _ = lines - lenses = append(lenses, CodeLens{ - File: file, - Line: sym.line, - Label: label, - Category: "references", - Command: "", - Tooltip: tooltip, - }) - } - } - return lenses -} - -func GenerateAgeLens(file, content string) []CodeLens { - var lenses []CodeLens - - functions := extractFunctions(content) - blameData := getGitBlame(file) - - for _, fn := range functions { - age := lookupAge(blameData, fn.line) - if age == "" { - continue - } - label := fmt.Sprintf("age: %s", age) - tooltip := fmt.Sprintf("func %s — ", fn.name) - if isRecent(age) { - tooltip += "recently modified" - } else { - tooltip += "last modified " + age + " ago" - } - lenses = append(lenses, CodeLens{ - File: file, - Line: fn.line, - Label: label, - Category: "age", - Command: fmt.Sprintf("git log --oneline -1 -L %d,%d:%s", fn.line, fn.endLine, file), - Tooltip: tooltip, - }) - } - return lenses -} - -func GenerateCoverageLens(file, content string) []CodeLens { - var lenses []CodeLens - - functions := extractFunctions(content) - coverageData := loadCoverageData(file) - if coverageData == nil { - return nil - } - - for _, fn := range functions { - pct, ok := coverageData[fn.name] - if !ok { - continue - } - label := fmt.Sprintf("coverage: %.0f%%", pct) - tooltip := fmt.Sprintf("func %s — ", fn.name) - if pct >= 80 { - tooltip += "well covered" - } else if pct >= 50 { - tooltip += "partially covered" - } else { - tooltip += "needs more tests" - } - lenses = append(lenses, CodeLens{ - File: file, - Line: fn.line, - Label: label, - Category: "coverage", - Command: fmt.Sprintf("go test -coverprofile=coverage.out -run . %s", file), - Tooltip: tooltip, - }) - } - return lenses -} - -type funcInfo struct { - name string - line int - endLine int - body string -} - -type symbolInfo struct { - name string - line int -} - -func extractFunctions(content string) []funcInfo { - var funcs []funcInfo - lines := strings.Split(content, "\n") - - for i := 0; i < len(lines); i++ { - matches := funcDeclRe.FindStringSubmatch(lines[i]) - if matches == nil { - continue - } - name := matches[1] - startLine := i + 1 - braceCount := 0 - started := false - var bodyLines []string - - for j := i; j < len(lines); j++ { - for _, ch := range lines[j] { - if ch == '{' { - braceCount++ - started = true - } else if ch == '}' { - braceCount-- - } - } - bodyLines = append(bodyLines, lines[j]) - if started && braceCount == 0 { - funcs = append(funcs, funcInfo{ - name: name, - line: startLine, - endLine: j + 1, - body: strings.Join(bodyLines, "\n"), - }) - break - } - } - } - return funcs -} - -func calculateCyclomaticComplexity(body string) int { - cc := 1 - decisionPatterns := []string{ - `\bif\b`, - `\belse if\b`, - `\bfor\b`, - `\bcase\b`, - `\b&&\b`, - `\b\|\|\b`, - `\bselect\b`, - } - words := strings.Fields(body) - for _, w := range words { - switch w { - case "if", "for", "case", "select": - cc++ - } - } - _ = decisionPatterns - cc += strings.Count(body, "&&") - cc += strings.Count(body, "||") - return cc -} - -func extractExportedSymbols(content string) []symbolInfo { - var symbols []symbolInfo - lines := strings.Split(content, "\n") - for i, line := range lines { - matches := funcDeclRe.FindStringSubmatch(line) - if matches != nil { - name := matches[1] - if len(name) > 0 && unicode.IsUpper(rune(name[0])) { - symbols = append(symbols, symbolInfo{name: name, line: i + 1}) - } - continue - } - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "type ") { - parts := strings.Fields(trimmed) - if len(parts) >= 2 { - name := parts[1] - if len(name) > 0 && unicode.IsUpper(rune(name[0])) { - symbols = append(symbols, symbolInfo{name: name, line: i + 1}) - } - } - } - } - return symbols -} - -func countReferences(content, symbol string) int { - count := strings.Count(content, symbol) - if count > 0 { - count-- - } - return count -} - -type blameEntry struct { - line int - date time.Time -} - -func getGitBlame(file string) []blameEntry { - cmd := exec.CommandContext(context.Background(), "git", "blame", "--porcelain", file) // #nosec G204 -- fixed git executable - out, err := cmd.Output() - if err != nil { - return nil - } - - var entries []blameEntry - lines := strings.Split(string(out), "\n") - lineNum := 0 - for _, l := range lines { - if strings.HasPrefix(l, "author-time ") { - ts := strings.TrimPrefix(l, "author-time ") - var epoch int64 - _, _ = fmt.Sscanf(ts, "%d", &epoch) - if epoch > 0 { - entries = append(entries, blameEntry{ - line: lineNum, - date: time.Unix(epoch, 0), - }) - } - } - parts := strings.Fields(l) - if len(parts) >= 3 && len(parts[0]) == 40 { - _, _ = fmt.Sscanf(parts[2], "%d", &lineNum) - } - } - return entries -} - -func lookupAge(entries []blameEntry, line int) string { - if len(entries) == 0 { - return "" - } - - var newest time.Time - for _, e := range entries { - if e.line >= line && e.line <= line+20 { - if e.date.After(newest) { - newest = e.date - } - } - } - - if newest.IsZero() { - for _, e := range entries { - if e.date.After(newest) { - newest = e.date - } - } - } - - if newest.IsZero() { - return "" - } - - return lensFormatDuration(time.Since(newest)) -} - -func lensFormatDuration(d time.Duration) string { - days := int(d.Hours() / 24) - if days == 0 { - hours := int(d.Hours()) - if hours == 0 { - return "just now" - } - return fmt.Sprintf("%dh", hours) - } - if days < 7 { - return fmt.Sprintf("%dd", days) - } - if days < 30 { - return fmt.Sprintf("%dw", days/7) - } - if days < 365 { - return fmt.Sprintf("%dmo", days/30) - } - return fmt.Sprintf("%dy", days/365) -} - -func isRecent(age string) bool { - if age == "just now" { - return true - } - if strings.HasSuffix(age, "h") { - return true - } - if strings.HasSuffix(age, "d") { - var days int - _, _ = fmt.Sscanf(age, "%dd", &days) - return days < 7 - } - return false -} - -func loadCoverageData(file string) map[string]float64 { - dir := file - if idx := strings.LastIndex(file, "/"); idx >= 0 { - dir = file[:idx] - } - coverFile := dir + "/coverage.out" - - cmd := exec.CommandContext(context.Background(), "cat", coverFile) // #nosec G204 -- invocation of standard unix utility with internally-derived path/pattern - out, err := cmd.Output() - if err != nil { - return nil - } - - return parseCoverageProfile(string(out), file) -} - -func parseCoverageProfile(profile, file string) map[string]float64 { - result := make(map[string]float64) - lines := strings.Split(profile, "\n") - - type blockInfo struct { - startLine int - endLine int - stmts int - count int - } - - var blocks []blockInfo - for _, line := range lines { - if !strings.Contains(line, ":") || strings.HasPrefix(line, "mode:") { - continue - } - parts := strings.Fields(line) - if len(parts) < 3 { - continue - } - loc := parts[0] - if !strings.Contains(loc, file) { - continue - } - colonIdx := strings.LastIndex(loc, ":") - if colonIdx < 0 { - continue - } - span := loc[colonIdx+1:] - rangeParts := strings.Split(span, ",") - if len(rangeParts) != 2 { - continue - } - var startLine, endLine, stmts, count int - _, _ = fmt.Sscanf(rangeParts[0], "%d", &startLine) - _, _ = fmt.Sscanf(rangeParts[1], "%d", &endLine) - _, _ = fmt.Sscanf(parts[1], "%d", &stmts) - _, _ = fmt.Sscanf(parts[2], "%d", &count) - blocks = append(blocks, blockInfo{startLine, endLine, stmts, count}) - } - - if len(blocks) == 0 { - return nil - } - - _ = result - _ = blocks - return nil -} diff --git a/internal/engine/code/code_lens_test.go b/internal/engine/code/code_lens_test.go deleted file mode 100644 index 456bc0e5d..000000000 --- a/internal/engine/code/code_lens_test.go +++ /dev/null @@ -1,626 +0,0 @@ -package code - -import ( - "strings" - "testing" - "time" -) - -func TestNewCodeLensProvider(t *testing.T) { - p := NewCodeLensProvider() - if p == nil { - t.Fatal("expected non-nil provider") - } - expected := []string{"test_status", "complexity", "references", "age", "coverage"} - for _, name := range expected { - if _, ok := p.Providers[name]; !ok { - t.Errorf("missing built-in provider: %s", name) - } - } -} - -func TestCodeLensRegister(t *testing.T) { - p := NewCodeLensProvider() - called := false - p.Register("custom", func(file, content string) []CodeLens { - called = true - return []CodeLens{{File: file, Line: 1, Label: "custom", Category: "custom"}} - }) - - if _, ok := p.Providers["custom"]; !ok { - t.Fatal("Register did not add the provider") - } - - lenses := p.Providers["custom"]("test.go", "") - if !called { - t.Error("custom generator was not called") - } - if len(lenses) != 1 { - t.Errorf("expected 1 lens, got %d", len(lenses)) - } -} - -func TestGenerate_SortsByLine(t *testing.T) { - p := &CodeLensProvider{ - Providers: make(map[string]LensGenerator), - } - p.Providers["a"] = func(file, content string) []CodeLens { - return []CodeLens{ - {File: file, Line: 10, Label: "a10", Category: "age"}, - {File: file, Line: 1, Label: "a1", Category: "age"}, - } - } - p.Providers["b"] = func(file, content string) []CodeLens { - return []CodeLens{ - {File: file, Line: 5, Label: "b5", Category: "complexity"}, - } - } - - lenses := p.Generate("file.go", "") - if len(lenses) != 3 { - t.Fatalf("expected 3 lenses, got %d", len(lenses)) - } - if lenses[0].Line != 1 { - t.Errorf("expected first lens at line 1, got %d", lenses[0].Line) - } - if lenses[1].Line != 5 { - t.Errorf("expected second lens at line 5, got %d", lenses[1].Line) - } - if lenses[2].Line != 10 { - t.Errorf("expected third lens at line 10, got %d", lenses[2].Line) - } -} - -func TestFilterByCategory(t *testing.T) { - lenses := []CodeLens{ - {Line: 1, Category: "complexity"}, - {Line: 2, Category: "test_status"}, - {Line: 3, Category: "complexity"}, - {Line: 4, Category: "age"}, - } - - result := FilterByCategory(lenses, "complexity") - if len(result) != 2 { - t.Errorf("expected 2, got %d", len(result)) - } - for _, l := range result { - if l.Category != "complexity" { - t.Errorf("expected category complexity, got %s", l.Category) - } - } - - result = FilterByCategory(lenses, "ownership") - if len(result) != 0 { - t.Errorf("expected 0 for non-existent category, got %d", len(result)) - } -} - -func TestFilterByCategory_Empty(t *testing.T) { - result := FilterByCategory(nil, "test_status") - if result != nil { - t.Errorf("expected nil for nil input, got %v", result) - } -} - -func TestFormatLenses(t *testing.T) { - lenses := []CodeLens{ - {File: "src/auth.go", Line: 5, Label: "complexity: 12", Category: "complexity", Tooltip: "func ValidateToken — consider splitting"}, - {File: "src/auth.go", Line: 15, Label: "references: 8", Category: "references", Tooltip: "func ParseClaims — widely used"}, - {File: "src/auth.go", Line: 42, Label: "age: 3d", Category: "age", Tooltip: "func RefreshToken — recently modified"}, - {File: "src/auth.go", Line: 60, Label: "test: PASS", Category: "test_status", Tooltip: "func TestValidateToken — passed"}, - } - - output := FormatLenses("src/auth.go", lenses) - if !strings.Contains(output, "Code Lenses for src/auth.go:") { - t.Error("missing header") - } - if !strings.Contains(output, "L5") { - t.Error("missing line 5") - } - if !strings.Contains(output, "[complexity: 12]") { - t.Error("missing complexity label") - } - if !strings.Contains(output, "[references: 8]") { - t.Error("missing references label") - } - if !strings.Contains(output, "[age: 3d]") { - t.Error("missing age label") - } - if !strings.Contains(output, "[test: PASS]") { - t.Error("missing test status label") - } - if !strings.Contains(output, "consider splitting") { - t.Error("missing tooltip text") - } -} - -func TestFormatLenses_Empty(t *testing.T) { - output := FormatLenses("empty.go", nil) - if !strings.Contains(output, "(none)") { - t.Error("expected (none) for empty lenses") - } -} - -func TestGenerateTestLens_NonTestFile(t *testing.T) { - lenses := GenerateTestLens("main.go", `package main - -func main() {} -`) - if lenses != nil { - t.Errorf("expected nil for non-test file, got %v", lenses) - } -} - -func TestGenerateTestLens_FindsTestFunctions(t *testing.T) { - content := `package foo - -import "testing" - -func TestAdd(t *testing.T) { - if add(1, 2) != 3 { - t.Fail() - } -} - -func TestSubtract(t *testing.T) { - if subtract(5, 3) != 2 { - t.Fail() - } -} - -func helperFunc() {} -` - // We use a custom provider that doesn't actually run tests - lenses := findTestFunctions("foo_test.go", content) - if len(lenses) != 2 { - t.Fatalf("expected 2 test lenses, got %d", len(lenses)) - } - if lenses[0].Line != 5 { - t.Errorf("expected TestAdd at line 5, got %d", lenses[0].Line) - } - if lenses[1].Line != 11 { - t.Errorf("expected TestSubtract at line 11, got %d", lenses[1].Line) - } - for _, l := range lenses { - if l.Category != "test_status" { - t.Errorf("expected category test_status, got %s", l.Category) - } - } -} - -func TestGenerateComplexityLens(t *testing.T) { - // Function with high complexity: many if/for/case/&& branches - content := `package foo - -func SimpleFunc() { - x := 1 - _ = x -} - -func ComplexFunc(x int) string { - if x > 0 { - if x > 10 { - for i := 0; i < x; i++ { - if i%2 == 0 { - if i > 5 && x > 20 { - return "high" - } - } - } - } - } - if x < 0 { - return "negative" - } - return "default" -} -` - lenses := GenerateComplexityLens("foo.go", content) - // ComplexFunc should exceed threshold - found := false - for _, l := range lenses { - if strings.Contains(l.Tooltip, "ComplexFunc") { - found = true - if l.Category != "complexity" { - t.Errorf("expected category complexity, got %s", l.Category) - } - } - } - if !found { - t.Error("expected ComplexFunc to be annotated with complexity lens") - } - - // SimpleFunc should not appear - for _, l := range lenses { - if strings.Contains(l.Tooltip, "SimpleFunc") { - t.Error("SimpleFunc should not exceed complexity threshold") - } - } -} - -func TestGenerateReferenceLens(t *testing.T) { - content := `package foo - -func ExportedFunc() { - // something -} - -func anotherFunc() { - ExportedFunc() - ExportedFunc() -} - -func yetAnother() { - ExportedFunc() -} -` - lenses := GenerateReferenceLens("foo.go", content) - if len(lenses) == 0 { - t.Fatal("expected at least one reference lens") - } - found := false - for _, l := range lenses { - if strings.Contains(l.Tooltip, "ExportedFunc") { - found = true - if l.Category != "references" { - t.Errorf("expected category references, got %s", l.Category) - } - // Should have at least 3 references (calls, excluding declaration) - if !strings.Contains(l.Label, "references:") { - t.Error("label should contain references count") - } - } - } - if !found { - t.Error("expected ExportedFunc to have reference lens") - } -} - -func TestGenerateReferenceLens_NoUnexported(t *testing.T) { - content := `package foo - -func unexported() {} - -func alsoUnexported() { - unexported() -} -` - lenses := GenerateReferenceLens("foo.go", content) - if len(lenses) != 0 { - t.Errorf("expected 0 lenses for unexported symbols, got %d", len(lenses)) - } -} - -func TestExtractFunctions(t *testing.T) { - content := `package foo - -func Foo() { - x := 1 - _ = x -} - -func (s *Server) Bar(ctx context.Context) error { - return nil -} -` - funcs := extractFunctions(content) - if len(funcs) != 2 { - t.Fatalf("expected 2 functions, got %d", len(funcs)) - } - if funcs[0].name != "Foo" { - t.Errorf("expected Foo, got %s", funcs[0].name) - } - if funcs[0].line != 3 { - t.Errorf("expected line 3, got %d", funcs[0].line) - } - if funcs[1].name != "Bar" { - t.Errorf("expected Bar, got %s", funcs[1].name) - } - if funcs[1].line != 8 { - t.Errorf("expected line 8, got %d", funcs[1].line) - } -} - -func TestCalculateCyclomaticComplexity(t *testing.T) { - tests := []struct { - name string - body string - expected int - }{ - { - name: "empty function", - body: "func f() {\n}", - expected: 1, - }, - { - name: "single if", - body: "func f() {\n if x > 0 { return }\n}", - expected: 2, - }, - { - name: "if with and", - body: "func f() {\n if x > 0 && y > 0 { return }\n}", - expected: 3, - }, - { - name: "for loop", - body: "func f() {\n for i := 0; i < 10; i++ { }\n}", - expected: 2, - }, - { - name: "switch cases", - body: `func f() { - switch x { - case 1: - return "a" - case 2: - return "b" - case 3: - return "c" - } -}`, - expected: 4, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := calculateCyclomaticComplexity(tt.body) - if got != tt.expected { - t.Errorf("calculateCyclomaticComplexity() = %d, want %d", got, tt.expected) - } - }) - } -} - -func TestExtractExportedSymbols(t *testing.T) { - content := `package foo - -type MyStruct struct {} - -func ExportedFunc() {} - -func unexportedFunc() {} - -type anotherType struct{} - -func (s *MyStruct) ExportedMethod() {} -` - symbols := extractExportedSymbols(content) - names := make(map[string]bool) - for _, s := range symbols { - names[s.name] = true - } - - if !names["MyStruct"] { - t.Error("missing MyStruct") - } - if !names["ExportedFunc"] { - t.Error("missing ExportedFunc") - } - if !names["ExportedMethod"] { - t.Error("missing ExportedMethod") - } - if names["unexportedFunc"] { - t.Error("unexportedFunc should not be included") - } - if names["anotherType"] { - t.Error("anotherType should not be included") - } -} - -func TestCountReferences(t *testing.T) { - content := `package foo - -func DoWork() {} - -func caller1() { DoWork() } -func caller2() { DoWork() } -func caller3() { DoWork() } -` - count := countReferences(content, "DoWork") - // 4 total occurrences minus 1 for declaration = 3 - if count != 3 { - t.Errorf("expected 3 references, got %d", count) - } -} - -func TestCountReferences_Zero(t *testing.T) { - content := `package foo - -func Unused() {} -` - count := countReferences(content, "Unused") - // 1 occurrence (declaration) minus 1 = 0 - if count != 0 { - t.Errorf("expected 0 references, got %d", count) - } -} - -func TestFormatDuration(t *testing.T) { - tests := []struct { - name string - hours float64 - expected string - }{ - {"just now", 0.5, "just now"}, - {"hours", 5, "5h"}, - {"days", 72, "3d"}, - {"weeks", 336, "2w"}, - {"months", 1440, "2mo"}, - {"years", 9000, "1y"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := time.Duration(tt.hours * float64(time.Hour)) - got := lensFormatDuration(d) - if got != tt.expected { - t.Errorf("lensFormatDuration(%v) = %s, want %s", d, got, tt.expected) - } - }) - } -} - -func TestIsRecent(t *testing.T) { - tests := []struct { - age string - recent bool - }{ - {"just now", true}, - {"3h", true}, - {"2d", true}, - {"6d", true}, - {"2w", false}, - {"3mo", false}, - {"1y", false}, - } - for _, tt := range tests { - t.Run(tt.age, func(t *testing.T) { - got := isRecent(tt.age) - if got != tt.recent { - t.Errorf("isRecent(%s) = %v, want %v", tt.age, got, tt.recent) - } - }) - } -} - -func TestCodeLensStruct(t *testing.T) { - lens := CodeLens{ - File: "src/auth.go", - Line: 42, - Label: "test: PASS", - Category: "test_status", - Command: "go test -run ^TestAuth$", - Tooltip: "func TestAuth — passed", - } - if lens.File != "src/auth.go" { - t.Error("File field mismatch") - } - if lens.Line != 42 { - t.Error("Line field mismatch") - } - if lens.Category != "test_status" { - t.Error("Category field mismatch") - } -} - -func TestGenerateAgeLens_NoGit(t *testing.T) { - // When not in a git repo, should return empty - lenses := GenerateAgeLens("/nonexistent/path.go", `package foo - -func Hello() {} -`) - if len(lenses) != 0 { - t.Errorf("expected 0 lenses outside git, got %d", len(lenses)) - } -} - -func TestGenerateCoverageLens_NoCoverageFile(t *testing.T) { - lenses := GenerateCoverageLens("/nonexistent/path.go", `package foo - -func Hello() {} -`) - if lenses != nil { - t.Errorf("expected nil when no coverage file, got %v", lenses) - } -} - -func TestGenerate_Integration(t *testing.T) { - p := &CodeLensProvider{ - Providers: make(map[string]LensGenerator), - } - // Only use deterministic generators for integration test - p.Providers["complexity"] = GenerateComplexityLens - p.Providers["references"] = GenerateReferenceLens - - content := `package foo - -func ExportedHighComplexity(x int) string { - if x > 0 { - if x > 10 { - for i := 0; i < x; i++ { - if i%2 == 0 { - if i > 5 && x > 20 { - return "high" - } - } - } - } - } - if x < 0 { - return "negative" - } - return "default" -} - -func caller() { - ExportedHighComplexity(1) - ExportedHighComplexity(2) -} -` - lenses := p.Generate("foo.go", content) - if len(lenses) == 0 { - t.Fatal("expected lenses from integration test") - } - - // Should have at least complexity and references - hasComplexity := false - hasReferences := false - for _, l := range lenses { - if l.Category == "complexity" { - hasComplexity = true - } - if l.Category == "references" { - hasReferences = true - } - } - if !hasComplexity { - t.Error("expected complexity lens") - } - if !hasReferences { - t.Error("expected references lens") - } -} - -func TestRegister_Overwrite(t *testing.T) { - p := NewCodeLensProvider() - original := p.Providers["complexity"] - if original == nil { - t.Fatal("complexity provider should exist") - } - - p.Register("complexity", func(file, content string) []CodeLens { - return []CodeLens{{File: file, Line: 99, Label: "custom", Category: "complexity"}} - }) - - lenses := p.Providers["complexity"]("test.go", "") - if len(lenses) != 1 || lenses[0].Line != 99 { - t.Error("Register should overwrite existing provider") - } -} - -// findTestFunctions is a helper that finds test functions without running them. -func findTestFunctions(file, content string) []CodeLens { - if !strings.HasSuffix(file, "_test.go") { - return nil - } - - var lenses []CodeLens - lines := strings.Split(content, "\n") - for i, line := range lines { - matches := testFuncRe.FindStringSubmatch(line) - if matches == nil { - continue - } - funcName := matches[1] - lenses = append(lenses, CodeLens{ - File: file, - Line: i + 1, - Label: "test: UNKNOWN", - Category: "test_status", - Command: "go test -run ^" + funcName + "$", - Tooltip: "func " + funcName + " — status unknown", - }) - } - return lenses -} diff --git a/internal/engine/code/coverage_extra_test.go b/internal/engine/code/coverage_extra_test.go deleted file mode 100644 index c73aed53e..000000000 --- a/internal/engine/code/coverage_extra_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package code - -import ( - "testing" -) - -func TestExtractIndent(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {" indented", " "}, - {"\ttabbed", "\t"}, - {"no indent", ""}, - {" mixed \ttabs", " "}, - {"", ""}, - {" ", " "}, - {"\t\t", "\t\t"}, - } - - for _, tt := range tests { - got := extractIndent(tt.input) - if got != tt.expected { - t.Errorf("extractIndent(%q) = %q, want %q", tt.input, got, tt.expected) - } - } -} - -func TestExtractIndent_EmptyString(t *testing.T) { - result := extractIndent("") - if result != "" { - t.Errorf("extractIndent(\"\") = %q, want empty", result) - } -} - -func TestExtractIndent_AllWhitespace(t *testing.T) { - result := extractIndent(" \t ") - if result != " \t " { - t.Errorf("extractIndent(\" \\t \") = %q, want \" \\t \"", result) - } -} - -func TestExtractIndent_OnlySpaces(t *testing.T) { - result := extractIndent(" ") - if result != " " { - t.Errorf("extractIndent(\" \") = %q, want \" \"", result) - } -} - -func TestExtractIndent_OnlyTabs(t *testing.T) { - result := extractIndent("\t\t\t") - if result != "\t\t\t" { - t.Errorf("extractIndent(\"\\t\\t\\t\") = %q, want \"\\t\\t\\t\"", result) - } -} - -func TestParseCoverageProfile_Empty(t *testing.T) { - result := parseCoverageProfile("", "test.go") - if len(result) != 0 { - t.Errorf("expected empty result for empty profile, got %d entries", len(result)) - } -} - -func TestParseCoverageProfile_OnlyMode(t *testing.T) { - result := parseCoverageProfile("mode: set\n", "test.go") - if len(result) != 0 { - t.Errorf("expected empty result for mode-only profile, got %d entries", len(result)) - } -} - -func TestParseCoverageProfile_WithData(t *testing.T) { - profile := "mode: set\n" + - "github.com/test/pkg/file.go:10.20,30 2 3\n" + - "github.com/test/pkg/other.go:5.10,15 1 2\n" - - result := parseCoverageProfile(profile, "file.go") - // Function is a stub that returns nil, but exercises parsing code - _ = result -} - -func TestParseCoverageProfile_NoMatchingFile(t *testing.T) { - profile := "mode: set\n" + - "github.com/test/pkg/other.go:5.10,15 1 2\n" - - result := parseCoverageProfile(profile, "file.go") - if result != nil { - t.Errorf("expected nil for non-matching file, got %v", result) - } -} - -func TestParseCoverageProfile_MalformedLine(t *testing.T) { - profile := "mode: set\n" + - "github.com/test/pkg/file.go:10.20,30 2\n" + // missing count - "github.com/test/pkg/file.go:bad\n" + // no colon in span - "github.com/test/pkg/file.go:10.bad,20 2 3\n" // bad range - - result := parseCoverageProfile(profile, "file.go") - // Should handle gracefully without panicking - _ = result -} - -func TestParseCoverageProfile_MultipleBlocks(t *testing.T) { - profile := "mode: set\n" + - "github.com/test/pkg/file.go:10.20,30 2 3\n" + - "github.com/test/pkg/file.go:40.50,60 1 0\n" - - result := parseCoverageProfile(profile, "file.go") - // Function is a stub that returns nil, but exercises parsing code - _ = result -} - -func TestLookupTestStatus_NonExistentFunction(t *testing.T) { - // This will run `go test` which will fail since the function doesn't exist - status := lookupTestStatus("nonexistent_file.go", "NonExistentFunction") - if status != "UNKNOWN" && status != "FAIL" { - t.Errorf("lookupTestStatus for non-existent function = %q, want UNKNOWN or FAIL", status) - } -} - -func TestLookupTestStatus_NonExistentFile(t *testing.T) { - status := lookupTestStatus("/nonexistent/path/file.go", "TestSomething") - // go test returns exit code 1 for non-existent directory - if status != "FAIL" && status != "UNKNOWN" { - t.Errorf("lookupTestStatus for non-existent file = %q, want FAIL or UNKNOWN", status) - } -} - -// --- Helper functions --- diff --git a/internal/engine/prompt/aliases.go b/internal/engine/prompt/aliases.go deleted file mode 100644 index ccc8cea13..000000000 --- a/internal/engine/prompt/aliases.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package prompt provides prompt-construction and prompt-optimisation types. -package prompt - -// Optimizer learns better prompts via DSPy-style example mining. -type Optimizer = PromptOptimizer - -// DSPyExample is one (input, output) demonstration backing the optimizer. -type DSPyExample = PromptExample - -// DSPyVariant is a candidate prompt being evaluated. -type DSPyVariant = PromptVariant - -// ABTest pits two prompt variants against each other on incoming traffic. -type ABTest struct { - A, B DSPyVariant -} - -// Tuner is a lighter-weight prompt-tuning helper for online adjustments. -type Tuner = PromptTuner - -// Variant is a tuned prompt the Tuner emits. -type Variant = PromptVariant - -// NewOptimizer returns a fresh prompt optimizer. -func NewOptimizer() *Optimizer { - return NewPromptOptimizer() -} - -// NewABTest builds an A/B test between two variants. -func NewABTest(a, b DSPyVariant) *ABTest { - return &ABTest{A: a, B: b} -} - -// NewTuner returns a fresh prompt tuner. -func NewTuner() *Tuner { - return NewPromptTuner() -} diff --git a/internal/engine/prompt/aliases_extra_test.go b/internal/engine/prompt/aliases_extra_test.go deleted file mode 100644 index 0f5253999..000000000 --- a/internal/engine/prompt/aliases_extra_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package prompt - -import ( - "testing" -) - -func TestNewOptimizer(t *testing.T) { - opt := NewOptimizer() - if opt == nil { - t.Fatal("expected non-nil Optimizer") - } -} - -func TestNewABTest(t *testing.T) { - a := DSPyVariant{Section: "section-a", Content: "content-a"} - b := DSPyVariant{Section: "section-b", Content: "content-b"} - test := NewABTest(a, b) - if test == nil { - t.Fatal("expected non-nil ABTest") - } - if test.A.Section != "section-a" { - t.Errorf("A.Section = %q, want %q", test.A.Section, "section-a") - } - if test.B.Section != "section-b" { - t.Errorf("B.Section = %q, want %q", test.B.Section, "section-b") - } -} - -func TestNewTuner(t *testing.T) { - tuner := NewTuner() - if tuner == nil { - t.Fatal("expected non-nil Tuner") - } -} - -func TestNewABTest_EmptyVariants(t *testing.T) { - test := NewABTest(DSPyVariant{}, DSPyVariant{}) - if test == nil { - t.Fatal("expected non-nil ABTest") - } -} diff --git a/internal/engine/prompt/prompt_extra_test.go b/internal/engine/prompt/prompt_extra_test.go deleted file mode 100644 index 53a596474..000000000 --- a/internal/engine/prompt/prompt_extra_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package prompt - -import ( - "context" - "testing" - - "github.com/GrayCodeAI/hawk/internal/types" -) - -// mockLLMClient implements LLMClient for testing -type mockLLMClient struct { - response string - err error -} - -func (m *mockLLMClient) Chat(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { - if m.err != nil { - return nil, m.err - } - return &types.EyrieResponse{Content: m.response}, nil -} - -func TestFormatFloat(t *testing.T) { - tests := []struct { - input float64 - expected string - }{ - {0.0, "0.0"}, - {1.5, "1.50"}, - {3.14, "3.14"}, - {123.45, "123.45"}, - } - for _, tt := range tests { - result := formatFloat(tt.input) - if result != tt.expected { - t.Errorf("formatFloat(%v) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestItoa2(t *testing.T) { - tests := []struct { - input int - expected string - }{ - {0, "0"}, - {1, "1"}, - {10, "10"}, - {99, "99"}, - {100, "100"}, - {-1, "-1"}, - {-10, "-10"}, - } - for _, tt := range tests { - result := itoa2(tt.input) - if result != tt.expected { - t.Errorf("itoa2(%d) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestOptimizePrompt(t *testing.T) { - po := NewPromptOptimizer() - po.Parameters["test_param"] = &PromptParameter{ - Name: "test_param", - Value: "original value", - } - - llm := &mockLLMClient{response: "optimized value"} - - result, err := OptimizePrompt(context.Background(), llm, "test-model", po, "test_param", "needs improvement") - if err != nil { - t.Fatalf("OptimizePrompt error: %v", err) - } - if result != "optimized value" { - t.Errorf("OptimizePrompt result = %q, want %q", result, "optimized value") - } -} - -func TestOptimizePrompt_ParameterNotFound(t *testing.T) { - po := NewPromptOptimizer() - llm := &mockLLMClient{response: "optimized"} - - _, err := OptimizePrompt(context.Background(), llm, "test-model", po, "nonexistent", "feedback") - if err == nil { - t.Error("expected error for non-existent parameter") - } -} - -func TestOptimizePrompt_LLMError(t *testing.T) { - po := NewPromptOptimizer() - po.Parameters["test_param"] = &PromptParameter{ - Name: "test_param", - Value: "original value", - } - - llm := &mockLLMClient{err: context.DeadlineExceeded} - - _, err := OptimizePrompt(context.Background(), llm, "test-model", po, "test_param", "feedback") - if err == nil { - t.Error("expected error from LLM") - } -} diff --git a/internal/engine/prompt/prompt_optimizer.go b/internal/engine/prompt/prompt_optimizer.go deleted file mode 100644 index 5aa5747e5..000000000 --- a/internal/engine/prompt/prompt_optimizer.go +++ /dev/null @@ -1,251 +0,0 @@ -package prompt - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/GrayCodeAI/hawk/internal/storage" - "github.com/GrayCodeAI/hawk/internal/types" -) - -// LLMClient is the minimal interface needed for prompt optimization. -type LLMClient interface { - Chat(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) -} - -type PromptParameter struct { - Name string `json:"name"` - Value string `json:"value"` - Score float64 `json:"score"` - Version int `json:"version"` -} - -type PromptGradient struct { - Parameter string `json:"parameter"` - Feedback string `json:"feedback"` - Direction string `json:"direction"` - Magnitude float64 `json:"magnitude"` -} - -type PromptOptimizer struct { - Parameters map[string]*PromptParameter - History []OptimizationStep - Path string -} - -type OptimizationStep struct { - Timestamp time.Time `json:"timestamp"` - Parameter string `json:"parameter"` - OldValue string `json:"old_value"` - NewValue string `json:"new_value"` - OldScore float64 `json:"old_score"` - NewScore float64 `json:"new_score"` - Gradient PromptGradient `json:"gradient"` -} - -func NewPromptOptimizer() *PromptOptimizer { - path := filepath.Join(storage.StateDir(), "prompt_params.json") - po := &PromptOptimizer{ - Parameters: make(map[string]*PromptParameter), - Path: path, - } - po.load() - return po -} - -func (po *PromptOptimizer) Register(name, initialValue string) { - if _, exists := po.Parameters[name]; !exists { - po.Parameters[name] = &PromptParameter{Name: name, Value: initialValue, Score: 0.5, Version: 1} - } -} - -func (po *PromptOptimizer) Get(name string) string { - if p, ok := po.Parameters[name]; ok { - return p.Value - } - return "" -} - -func (po *PromptOptimizer) RecordSuccess(paramName string) { - if p, ok := po.Parameters[paramName]; ok { - p.Score = p.Score*0.8 + 1.0*0.2 - po.save() - } -} - -func (po *PromptOptimizer) RecordFailure(paramName, feedback string) { - if p, ok := po.Parameters[paramName]; ok { - p.Score = p.Score*0.8 + 0.0*0.2 - po.save() - } -} - -func ComputeGradientPrompt(paramName, currentValue, feedback string, examples []string) string { - var exampleSection string - if len(examples) > 0 { - exampleSection = "\n\nSuccessful examples for reference:\n" + strings.Join(examples, "\n---\n") - } - - return fmt.Sprintf(`You are a prompt optimizer. A prompt parameter is underperforming. - -PARAMETER: %s -CURRENT VALUE: -%s - -FAILURE FEEDBACK: -%s -%s -TASK: Rewrite the parameter value to address the feedback. Keep the same intent but improve clarity, specificity, or correctness. - -Respond with ONLY the improved prompt text, nothing else.`, paramName, currentValue, feedback, exampleSection) -} - -func (po *PromptOptimizer) ApplyGradient(paramName, newValue string, gradient PromptGradient) { - p, ok := po.Parameters[paramName] - if !ok { - return - } - step := OptimizationStep{ - Timestamp: time.Now(), - Parameter: paramName, - OldValue: p.Value, - NewValue: newValue, - OldScore: p.Score, - Gradient: gradient, - } - p.Value = newValue - p.Version++ - p.Score = 0.5 - po.History = append(po.History, step) - po.save() -} - -func (po *PromptOptimizer) NeedsOptimization(threshold float64) []*PromptParameter { - var weak []*PromptParameter - for _, p := range po.Parameters { - if p.Score < threshold { - weak = append(weak, p) - } - } - return weak -} - -func OptimizePrompt(ctx context.Context, llm LLMClient, model string, po *PromptOptimizer, paramName, feedback string) (string, error) { - p, ok := po.Parameters[paramName] - if !ok { - return "", fmt.Errorf("parameter %q not found", paramName) - } - - prompt := ComputeGradientPrompt(paramName, p.Value, feedback, nil) - msgs := []types.EyrieMessage{{Role: "user", Content: prompt}} - resp, err := llm.Chat(ctx, msgs, types.ChatOptions{Model: model}) - if err != nil { - return "", err - } - - newValue := strings.TrimSpace(resp.Content) - gradient := PromptGradient{ - Parameter: paramName, - Feedback: feedback, - Direction: "improved based on failure feedback", - Magnitude: 0.5, - } - po.ApplyGradient(paramName, newValue, gradient) - return newValue, nil -} - -type PromptFewShotSelector struct { - Examples []PromptExample -} - -type PromptExample struct { - Input string `json:"input"` - Output string `json:"output"` - Score float64 `json:"score"` - Category string `json:"category"` -} - -func (fs *PromptFewShotSelector) Select(query string, k int) []PromptExample { - if len(fs.Examples) == 0 || k <= 0 { - return nil - } - - queryWords := strings.Fields(strings.ToLower(query)) - - type scored struct { - example PromptExample - score float64 - } - - var results []scored - for _, ex := range fs.Examples { - exWords := strings.Fields(strings.ToLower(ex.Input)) - overlap := promptWordOverlap(queryWords, exWords) - finalScore := overlap * (0.5 + ex.Score*0.5) - results = append(results, scored{example: ex, score: finalScore}) - } - - for i := 0; i < k && i < len(results); i++ { - maxIdx := i - for j := i + 1; j < len(results); j++ { - if results[j].score > results[maxIdx].score { - maxIdx = j - } - } - results[i], results[maxIdx] = results[maxIdx], results[i] - } - - var selected []PromptExample - for i := 0; i < k && i < len(results); i++ { - selected = append(selected, results[i].example) - } - return selected -} - -func FormatPromptExamples(examples []PromptExample) string { - if len(examples) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("## Examples\n") - for i, ex := range examples { - sb.WriteString(fmt.Sprintf("\n### Example %d\nInput: %s\nOutput: %s\n", i+1, ex.Input, ex.Output)) - } - return sb.String() -} - -func promptWordOverlap(a, b []string) float64 { - if len(a) == 0 || len(b) == 0 { - return 0 - } - bSet := make(map[string]bool) - for _, w := range b { - bSet[w] = true - } - matches := 0 - for _, w := range a { - if bSet[w] { - matches++ - } - } - return float64(matches) / float64(len(a)) -} - -func (po *PromptOptimizer) load() { - data, err := os.ReadFile(po.Path) - if err != nil { - return - } - _ = json.Unmarshal(data, &po.Parameters) -} - -func (po *PromptOptimizer) save() { - _ = os.MkdirAll(filepath.Dir(po.Path), 0o750) - data, _ := json.MarshalIndent(po.Parameters, "", " ") - _ = os.WriteFile(po.Path, data, 0o600) -} diff --git a/internal/engine/prompt/prompt_optimizer_test.go b/internal/engine/prompt/prompt_optimizer_test.go deleted file mode 100644 index 82844a5ab..000000000 --- a/internal/engine/prompt/prompt_optimizer_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package prompt - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestPromptOptimizer_RegisterAndGet(t *testing.T) { - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: filepath.Join(t.TempDir(), "params.json")} - po.Register("system", "You are a helpful assistant") - got := po.Get("system") - if got != "You are a helpful assistant" { - t.Errorf("got %q", got) - } -} - -func TestPromptOptimizer_RecordSuccess(t *testing.T) { - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: filepath.Join(t.TempDir(), "params.json")} - po.Register("test", "value") - po.RecordSuccess("test") - po.RecordSuccess("test") - if po.Parameters["test"].Score <= 0.5 { - t.Errorf("score should increase after success, got %f", po.Parameters["test"].Score) - } -} - -func TestPromptOptimizer_RecordFailure(t *testing.T) { - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: filepath.Join(t.TempDir(), "params.json")} - po.Register("test", "value") - po.RecordFailure("test", "bad output") - po.RecordFailure("test", "still bad") - if po.Parameters["test"].Score >= 0.5 { - t.Errorf("score should decrease after failure, got %f", po.Parameters["test"].Score) - } -} - -func TestPromptOptimizer_NeedsOptimization(t *testing.T) { - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: filepath.Join(t.TempDir(), "params.json")} - po.Register("good", "works well") - po.Register("bad", "doesn't work") - po.Parameters["good"].Score = 0.9 - po.Parameters["bad"].Score = 0.2 - - weak := po.NeedsOptimization(0.5) - if len(weak) != 1 { - t.Errorf("expected 1 weak param, got %d", len(weak)) - } - if weak[0].Name != "bad" { - t.Errorf("expected 'bad', got %q", weak[0].Name) - } -} - -func TestPromptOptimizer_ApplyGradient(t *testing.T) { - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: filepath.Join(t.TempDir(), "params.json")} - po.Register("prompt", "old value") - po.ApplyGradient("prompt", "new improved value", PromptGradient{Parameter: "prompt", Direction: "be more specific"}) - if po.Get("prompt") != "new improved value" { - t.Errorf("got %q", po.Get("prompt")) - } - if po.Parameters["prompt"].Version != 2 { - t.Errorf("version = %d, want 2", po.Parameters["prompt"].Version) - } -} - -func TestPromptOptimizer_Persistence(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "params.json") - - po1 := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: path} - po1.Register("key", "value") - po1.save() - - po2 := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: path} - po2.load() - if po2.Get("key") != "value" { - t.Errorf("persistence failed, got %q", po2.Get("key")) - } -} - -func TestFewShotSelector_Select(t *testing.T) { - fs := &PromptFewShotSelector{ - Examples: []PromptExample{ - {Input: "fix the nil pointer in auth.go", Output: "added nil check", Score: 0.9, Category: "bug-fix"}, - {Input: "add pagination to users API", Output: "added limit/offset", Score: 0.8, Category: "feature"}, - {Input: "fix race condition in cache", Output: "added mutex", Score: 0.7, Category: "bug-fix"}, - }, - } - - selected := fs.Select("fix the nil pointer dereference in parser.go", 2) - if len(selected) != 2 { - t.Fatalf("expected 2 examples, got %d", len(selected)) - } - if selected[0].Input != "fix the nil pointer in auth.go" { - t.Errorf("expected nil pointer example first, got %q", selected[0].Input) - } -} - -func TestFewShotSelector_Empty(t *testing.T) { - fs := &PromptFewShotSelector{} - selected := fs.Select("anything", 3) - if len(selected) != 0 { - t.Error("expected empty for no examples") - } -} - -func TestComputeGradientPrompt(t *testing.T) { - prompt := ComputeGradientPrompt("system_prompt", "You are helpful", "Too verbose, wastes tokens", nil) - if !strings.Contains(prompt, "system_prompt") { - t.Error("expected param name") - } - if !strings.Contains(prompt, "Too verbose") { - t.Error("expected feedback") - } -} - -func TestFormatPromptExamples(t *testing.T) { - examples := []PromptExample{{Input: "fix bug", Output: "fixed"}} - out := FormatPromptExamples(examples) - if !strings.Contains(out, "fix bug") { - t.Error("expected example in output") - } -} - -func TestPromptOptimizer_SaveLoadFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.json") - po := &PromptOptimizer{Parameters: make(map[string]*PromptParameter), Path: path} - po.Register("x", "y") - po.save() - if _, err := os.Stat(path); err != nil { - t.Error("file not created") - } -} diff --git a/internal/engine/prompt/prompt_tuner.go b/internal/engine/prompt/prompt_tuner.go deleted file mode 100644 index f92222b43..000000000 --- a/internal/engine/prompt/prompt_tuner.go +++ /dev/null @@ -1,148 +0,0 @@ -package prompt - -import ( - "encoding/json" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - "github.com/GrayCodeAI/hawk/internal/storage" -) - -type PromptTuner struct { - mu sync.Mutex - variants []PromptVariant - path string -} - -type PromptVariant struct { - Section string `json:"section"` - Content string `json:"content"` - Score float64 `json:"score"` - Uses int `json:"uses"` - Successes int `json:"successes"` - LastUsed time.Time `json:"last_used"` -} - -func NewPromptTuner() *PromptTuner { - pt := &PromptTuner{ - path: filepath.Join(storage.StateDir(), "prompt_tuning.json"), - } - pt.load() - return pt -} - -func (pt *PromptTuner) RecordOutcome(section, content string, success bool) { - pt.mu.Lock() - defer pt.mu.Unlock() - - for i, v := range pt.variants { - if v.Section == section && v.Content == content { - pt.variants[i].Uses++ - if success { - pt.variants[i].Successes++ - } - pt.variants[i].Score = float64(pt.variants[i].Successes) / float64(pt.variants[i].Uses) - pt.variants[i].LastUsed = time.Now() - pt.save() - return - } - } - - s := 0 - if success { - s = 1 - } - pt.variants = append(pt.variants, PromptVariant{ - Section: section, - Content: content, - Score: float64(s), - Uses: 1, - Successes: s, - LastUsed: time.Now(), - }) - pt.save() -} - -func (pt *PromptTuner) BestVariant(section string) (string, float64) { - pt.mu.Lock() - defer pt.mu.Unlock() - - var best *PromptVariant - for i, v := range pt.variants { - if v.Section != section || v.Uses < 3 { - continue - } - if best == nil || v.Score > best.Score { - best = &pt.variants[i] - } - } - if best != nil { - return best.Content, best.Score - } - return "", 0 -} - -func (pt *PromptTuner) Report() string { - pt.mu.Lock() - defer pt.mu.Unlock() - - sorted := make([]PromptVariant, len(pt.variants)) - copy(sorted, pt.variants) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score }) - - var b strings.Builder - b.WriteString("## Prompt Tuning Report\n") - for _, v := range sorted { - if v.Uses < 2 { - continue - } - b.WriteString(" " + v.Section + ": score=" + formatFloat(v.Score) + " (" + itoa2(v.Successes) + "/" + itoa2(v.Uses) + ")\n") - } - return b.String() -} - -func (pt *PromptTuner) load() { - data, err := os.ReadFile(pt.path) - if err != nil { - return - } - _ = json.Unmarshal(data, &pt.variants) -} - -func (pt *PromptTuner) save() { - dir := filepath.Dir(pt.path) - _ = os.MkdirAll(dir, 0o750) - data, _ := json.Marshal(pt.variants) - _ = os.WriteFile(pt.path, data, 0o600) -} - -func formatFloat(f float64) string { - s := "" - whole := int(f * 100) - s = itoa2(whole/100) + "." + itoa2(whole%100) - return s -} - -func itoa2(n int) string { - if n == 0 { - return "0" - } - neg := false - if n < 0 { - neg = true - n = -n - } - s := "" - for n > 0 { - s = string(rune('0'+n%10)) + s - n /= 10 - } - if neg { - s = "-" + s - } - return s -} diff --git a/internal/engine/prompt/prompt_tuner_test.go b/internal/engine/prompt/prompt_tuner_test.go deleted file mode 100644 index a6ee498be..000000000 --- a/internal/engine/prompt/prompt_tuner_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package prompt - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/internal/testutil" -) - -func TestPromptTuner_RecordAndBest(t *testing.T) { - testutil.IsolateStorage(t) - - pt := NewPromptTuner() - pt.RecordOutcome("tools", "Use tools freely", true) - pt.RecordOutcome("tools", "Use tools freely", true) - pt.RecordOutcome("tools", "Ask before using tools", false) - - best, score := pt.BestVariant("tools") - _, _ = best, score -} - -func TestPromptTuner_Report(t *testing.T) { - testutil.IsolateStorage(t) - - pt := NewPromptTuner() - pt.RecordOutcome("style", "concise", true) - pt.RecordOutcome("style", "verbose", false) - - report := pt.Report() - if report == "" { - t.Error("Report should be non-empty") - } -} - -func TestPromptTuner_BestVariant_Empty(t *testing.T) { - testutil.IsolateStorage(t) - - pt := NewPromptTuner() - best, _ := pt.BestVariant("nonexistent") - if best != "" { - t.Errorf("BestVariant on empty = %q, want empty", best) - } -} diff --git a/internal/engine/trajectory/trajectory.go b/internal/engine/trajectory/trajectory.go deleted file mode 100644 index 3c6ed2304..000000000 --- a/internal/engine/trajectory/trajectory.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package trajectory implements trajectory compression for agent runs, -// adopting Hermes Agent's trajectory_compressor: protect the first and last -// N turns verbatim (including their tool calls), compress only the middle into -// a single human summary message, and keep the surviving tool calls intact so -// training/eval signal is preserved. -package trajectory - -import ( - "strings" - - "github.com/GrayCodeAI/hawk/internal/types" -) - -// bytesPerToken is the rough character-based token estimate. -const bytesPerToken = 4 - -// estTokens estimates the token cost of a message from its text plus tool -// payload sizes. -func estTokens(m types.EyrieMessage) int { - n := len(m.Content) - for _, tu := range m.ToolUse { - if s, ok := tu.Arguments["_raw"]; ok { - if str, ok := s.(string); ok { - n += len(str) - } - } - for _, v := range tu.Arguments { - if str, ok := v.(string); ok { - n += len(str) - } - } - } - for _, tr := range m.ToolResults { - n += len(tr.Content) - } - return n / bytesPerToken -} - -// CompressTrajectory returns a trajectory where the first protectFirst turns -// and the last protectLast turns are kept verbatim and the middle is replaced -// by a single human summary message, only when the total exceeds -// targetTokens. The summary is a plain text "user" message so downstream -// consumers treat it as a faithful human-supplied checkpoint. Tool calls in -// the kept head/tail remain intact. -// -// Returns (msgs, false) unchanged when already under budget or there is no -// compressible middle. -func CompressTrajectory(msgs []types.EyrieMessage, targetTokens, protectFirst, protectLast int) ([]types.EyrieMessage, bool) { - if len(msgs) == 0 || targetTokens <= 0 { - return msgs, false - } - total := 0 - for _, m := range msgs { - total += estTokens(m) - } - if total <= targetTokens { - return msgs, false - } - if protectFirst < 0 { - protectFirst = 0 - } - if protectLast < 0 { - protectLast = 0 - } - // Ensure head and tail do not overlap. - if protectFirst+protectLast >= len(msgs) { - return msgs, false // no middle to compress - } - - head := msgs[:protectFirst] - tail := msgs[len(msgs)-protectLast:] - middle := msgs[protectFirst : len(msgs)-protectLast] - - summary := summarizeMiddle(middle) - out := make([]types.EyrieMessage, 0, len(head)+len(tail)+1) - out = append(out, head...) - out = append(out, types.EyrieMessage{ - Role: "user", - Content: "[compressed middle: " + summary + "]", - }) - out = append(out, tail...) - return out, true -} - -// summarizeMiddle reduces the middle region to a compact one-line digest. It -// counts turns and tool calls and captures the last user request, which is the -// most task-relevant signal for a human checkpoint. -func summarizeMiddle(middle []types.EyrieMessage) string { - turns := len(middle) - toolCalls := 0 - lastUser := "" - for _, m := range middle { - toolCalls += len(m.ToolUse) - if m.Role == "user" && strings.TrimSpace(m.Content) != "" { - lastUser = strings.TrimSpace(m.Content) - } - } - var b strings.Builder - b.WriteString("summarized ") - b.WriteString(itoa(turns)) - b.WriteString(" turns, ") - b.WriteString(itoa(toolCalls)) - b.WriteString(" tool calls") - if lastUser != "" { - lastUser = strings.ReplaceAll(lastUser, "\n", " ") - if len(lastUser) > 160 { - lastUser = lastUser[:160] + "…" - } - b.WriteString("; latest request: ") - b.WriteString(lastUser) - } - return b.String() -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := n < 0 - if neg { - n = -n - } - var b [20]byte - i := len(b) - for n > 0 { - i-- - b[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - b[i] = '-' - } - return string(b[i:]) -} diff --git a/internal/engine/trajectory/trajectory_test.go b/internal/engine/trajectory/trajectory_test.go deleted file mode 100644 index 6cd2655f7..000000000 --- a/internal/engine/trajectory/trajectory_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package trajectory - -import ( - "strings" - "testing" - - "github.com/GrayCodeAI/hawk/internal/types" -) - -func bigToolMsg(payload string) types.EyrieMessage { - return types.EyrieMessage{ - Role: "assistant", - ToolUse: []types.ToolCall{{Name: "Bash", Arguments: map[string]interface{}{"command": payload}}}, - ToolResults: []types.ToolResult{{Content: payload}}, - } -} - -func TestUnderBudgetUnchanged(t *testing.T) { - msgs := []types.EyrieMessage{{Role: "user", Content: "hi"}} - out, changed := CompressTrajectory(msgs, 10000, 2, 2) - if changed || len(out) != 1 { - t.Fatal("small trajectory must be unchanged") - } -} - -func TestProtectHeadTailCompressMiddle(t *testing.T) { - msgs := []types.EyrieMessage{ - {Role: "user", Content: "start"}, - bigToolMsg(strings.Repeat("a", 4000)), // head turn with tool call - {Role: "user", Content: strings.Repeat("b", 3000)}, - {Role: "assistant", Content: strings.Repeat("c", 3000)}, - {Role: "user", Content: strings.Repeat("d", 3000)}, - {Role: "user", Content: "end"}, // tail - } - // protectFirst=2, protectLast=1; target small enough to force compression - out, changed := CompressTrajectory(msgs, 400, 2, 1) - if !changed { - t.Fatal("expected compression") - } - // head (2) + summary + tail (1) = 4 messages - if len(out) != 4 { - t.Fatalf("len = %d, want 4", len(out)) - } - // head verbatim (incl. tool call) - if out[0].Content != "start" { - t.Fatalf("head[0] altered: %q", out[0].Content) - } - if len(out[1].ToolUse) != 1 || len(out[1].ToolResults) != 1 { - t.Fatal("head tool call not preserved") - } - // summary - if out[2].Role != "user" || !strings.Contains(out[2].Content, "[compressed middle") { - t.Fatalf("summary missing: %+v", out[2]) - } - // tail verbatim - if out[3].Content != "end" { - t.Fatalf("tail altered: %q", out[3].Content) - } -} - -func TestNoMiddleWhenProtectCoversAll(t *testing.T) { - msgs := []types.EyrieMessage{ - {Role: "user", Content: "a"}, - {Role: "user", Content: strings.Repeat("b", 5000)}, - {Role: "user", Content: "c"}, - } - out, changed := CompressTrajectory(msgs, 100, 2, 2) - if changed || len(out) != 3 { - t.Fatal("overlapping protect must leave unchanged") - } -} - -func TestSummaryCapturesLatestRequest(t *testing.T) { - msgs := []types.EyrieMessage{ - {Role: "user", Content: "old"}, - {Role: "user", Content: "the real request here"}, - {Role: "assistant", Content: strings.Repeat("x", 3000)}, - } - out, changed := CompressTrajectory(msgs, 200, 1, 0) - if !changed { - t.Fatal("expected change") - } - if !strings.Contains(out[1].Content, "the real request here") { - t.Fatalf("summary lost latest request: %q", out[1].Content) - } -} diff --git a/internal/fastcopy/clone_darwin.go b/internal/fastcopy/clone_darwin.go deleted file mode 100644 index 1a3a93c32..000000000 --- a/internal/fastcopy/clone_darwin.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build darwin - -package fastcopy - -import ( - "os" - - "golang.org/x/sys/unix" -) - -// tryCloneFilePlatform clones src into dst via APFS clonefile. -func tryCloneFilePlatform(src, dst string) (int64, bool) { - if err := unix.Clonefile(src, dst, 0); err != nil { - return 0, false - } - info, err := os.Stat(src) - if err != nil { - return 0, false - } - return info.Size(), true -} diff --git a/internal/fastcopy/clone_other.go b/internal/fastcopy/clone_other.go deleted file mode 100644 index efcf1335a..000000000 --- a/internal/fastcopy/clone_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !darwin - -package fastcopy - -// tryCloneFilePlatform reports unsupported; callers fall back to byte copies. -func tryCloneFilePlatform(src, dst string) (int64, bool) { - return 0, false -} diff --git a/internal/fastcopy/fastcopy.go b/internal/fastcopy/fastcopy.go deleted file mode 100644 index 6ba173291..000000000 --- a/internal/fastcopy/fastcopy.go +++ /dev/null @@ -1,182 +0,0 @@ -// Package fastcopy copies directory trees using copy-on-write where the -// filesystem supports it (APFS clonefile on macOS, FICLONE ioctl on Linux), -// falling back to plain byte copies elsewhere. Parallelism is sharded by -// parent directory so files in the same directory always land on the same -// worker, avoiding create-dir lock contention — the technique grok-build's -// xai-fast-worktree uses to make standalone worktree creation O(file_count) -// instead of a serial walk. -package fastcopy - -import ( - "context" - "crypto/sha256" - "encoding/binary" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" -) - -// maxWorkers bounds the copy worker pool. macOS defaults to a low soft -// file-descriptor limit, so it gets fewer workers than Linux. -func maxWorkers() int { - if runtime.GOOS == "darwin" { - return 8 - } - return 32 -} - -// shardCount is the number of work shards. Prime counts distribute better. -const shardCount = 16 - -func shardFor(dir string) int { - sum := sha256.Sum256([]byte(dir)) - return int(binary.BigEndian.Uint32(sum[:4]) % shardCount) -} - -// Tree copies the directory tree at src to dst. Existing dst content is -// overwritten file-by-file. Returns the number of files copied and bytes -// written. ctx cancellation stops the walk at the next file. -func Tree(ctx context.Context, src, dst string) (files int64, bytes int64, err error) { - info, err := os.Stat(src) - if err != nil { - return 0, 0, fmt.Errorf("fastcopy: stat src: %w", err) - } - if !info.IsDir() { - return 0, 0, fmt.Errorf("fastcopy: src is not a directory: %s", src) - } - - type job struct{ rel string } - jobs := make([][]job, shardCount) - - walkErr := filepath.WalkDir(src, func(path string, d os.DirEntry, werr error) error { - if werr != nil { - return werr - } - if ctx.Err() != nil { - return ctx.Err() - } - rel, rerr := filepath.Rel(src, path) - if rerr != nil { - return rerr - } - if rel == "." { - return os.MkdirAll(dst, 0o750) - } - if d.IsDir() { - return os.MkdirAll(filepath.Join(dst, rel), 0o750) - } - if !d.Type().IsRegular() { - return nil // skip symlinks/devices/fifos in workspace copies - } - s := shardFor(filepath.Dir(rel)) - jobs[s] = append(jobs[s], job{rel: rel}) - return nil - }) - if walkErr != nil { - return atomic.LoadInt64(&files), atomic.LoadInt64(&bytes), walkErr - } - - var ( - wg sync.WaitGroup - mu sync.Mutex - firstErr error - fileCnt atomic.Int64 - byteCnt atomic.Int64 - ) - for s := 0; s < shardCount; s++ { - shardJobs := jobs[s] - if len(shardJobs) == 0 { - continue - } - wg.Add(1) - go func(jobs []job) { - defer wg.Done() - for _, j := range jobs { - if ctx.Err() != nil { - return - } - n, err := copyFileCoW( - filepath.Join(src, j.rel), - filepath.Join(dst, j.rel), - ) - fileCnt.Add(1) - byteCnt.Add(n) - if err != nil { - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() - return - } - } - }(shardJobs) - if s+1 >= maxWorkers() { - // Reuse finished workers before spawning more shards. - wg.Wait() - } - } - wg.Wait() - if firstErr != nil { - return fileCnt.Load(), byteCnt.Load(), firstErr - } - return fileCnt.Load(), byteCnt.Load(), ctx.Err() -} - -// copyFileCoW clones src into dst via CoW when possible, else a byte copy. -func copyFileCoW(src, dst string) (int64, error) { - if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { - return 0, err - } - if n, cloned := tryCloneFile(src, dst); cloned { - return n, nil - } - in, err := os.Open(src) // #nosec G304 -- caller-supplied tree paths - if err != nil { - return 0, err - } - defer func() { _ = in.Close() }() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) // #nosec G304 -- mirrored tree path - if err != nil { - return 0, err - } - n, err := io.Copy(out, in) - cerr := out.Close() - if err == nil { - err = cerr - } - return n, err -} - -// tryCloneFile attempts a filesystem-level clone; returns cloned=false when -// unsupported (caller falls back to a byte copy, which stays correct -// everywhere). The per-platform implementations live in clone_darwin.go and -// clone_other.go. -func tryCloneFile(src, dst string) (int64, bool) { - return tryCloneFilePlatform(src, dst) -} - -// SupportsCloneFile reports whether a quick probe clone succeeds on the -// filesystem containing dir (used by callers to log/choose strategies). -func SupportsCloneFile(dir string) bool { - probeSrc := filepath.Join(dir, ".hawk-fastcopy-probe") - if err := os.WriteFile(probeSrc, []byte("probe"), 0o600); err != nil { - return false - } - defer func() { _ = os.Remove(probeSrc) }() - probeDst := probeSrc + ".clone" - defer func() { _ = os.Remove(probeDst) }() - _, ok := tryCloneFile(probeSrc, probeDst) - return ok -} - -// TrimPrefixPath is a tiny helper for logging relative paths without leaking -// absolute prefixes. -func TrimPrefixPath(root, p string) string { - return strings.TrimPrefix(strings.TrimPrefix(p, root), string(filepath.Separator)) -} diff --git a/internal/fastcopy/fastcopy_test.go b/internal/fastcopy/fastcopy_test.go deleted file mode 100644 index 58cbc6827..000000000 --- a/internal/fastcopy/fastcopy_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package fastcopy - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -func writeTree(t *testing.T, root string, files map[string]string) { - t.Helper() - for rel, content := range files { - path := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } -} - -func TestTreeCopiesAllFiles(t *testing.T) { - src := t.TempDir() - dst := filepath.Join(t.TempDir(), "copy") - writeTree(t, src, map[string]string{ - "a.txt": "alpha", - "sub/b.txt": "beta", - "sub/deep/c.txt": "gamma", - "other/d.txt": stringsRepeat("x", 10000), - }) - files, bytes, err := Tree(context.Background(), src, dst) - if err != nil { - t.Fatalf("Tree: %v", err) - } - if files != 4 { - t.Fatalf("files = %d, want 4", files) - } - if bytes < 10010 { - t.Fatalf("bytes = %d", bytes) - } - for _, rel := range []string{"a.txt", "sub/b.txt", "sub/deep/c.txt", "other/d.txt"} { - got, err := os.ReadFile(filepath.Join(dst, rel)) // #nosec G304 -- test-owned path - if err != nil { - t.Fatalf("missing %s: %v", rel, err) - } - want, _ := os.ReadFile(filepath.Join(src, rel)) // #nosec G304 -- test-owned path - if string(got) != string(want) { - t.Fatalf("%s content mismatch", rel) - } - } -} - -func TestTreeEmptyDir(t *testing.T) { - src := t.TempDir() - dst := filepath.Join(t.TempDir(), "copy") - files, _, err := Tree(context.Background(), src, dst) - if err != nil || files != 0 { - t.Fatalf("files=%d err=%v", files, err) - } - if _, err := os.Stat(dst); err != nil { - t.Fatalf("dst not created: %v", err) - } -} - -func TestTreeSrcMustExist(t *testing.T) { - if _, _, err := Tree(context.Background(), "/nonexistent-fastcopy-src", t.TempDir()); err == nil { - t.Fatal("expected error for missing src") - } -} - -func TestShardForDeterministic(t *testing.T) { - a := shardFor("sub") - b := shardFor("sub") - c := shardFor("other") - if a != b || c >= shardCount || a >= shardCount { - t.Fatalf("sharding broken: %d %d %d", a, b, c) - } -} - -func stringsRepeat(s string, n int) string { - out := make([]byte, 0, len(s)*n) - for i := 0; i < n; i++ { - out = append(out, s...) - } - return string(out) -} diff --git a/internal/feature/ide/ide.go b/internal/feature/ide/ide.go deleted file mode 100644 index 7822c05de..000000000 --- a/internal/feature/ide/ide.go +++ /dev/null @@ -1,151 +0,0 @@ -// Package ide provides IDE integration hints and metadata. -// This is a stub for future IDE extensions. -package ide - -import ( - "encoding/json" - "fmt" -) - -// ExtensionManifest describes a VSCode/Cursor/Trae extension. -type ExtensionManifest struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Version string `json:"version"` - Publisher string `json:"publisher"` - Description string `json:"description"` - Main string `json:"main"` - Engines map[string]string `json:"engines"` - Categories []string `json:"categories"` - Activation []string `json:"activationEvents"` - Contributes *Contributes `json:"contributes,omitempty"` -} - -// Contributes describes extension contributions. -type Contributes struct { - Commands []Command `json:"commands,omitempty"` - Keybindings []Keybinding `json:"keybindings,omitempty"` - Configuration *Configuration `json:"configuration,omitempty"` -} - -// Command describes a contributed command. -type Command struct { - Command string `json:"command"` - Title string `json:"title"` - Category string `json:"category,omitempty"` -} - -// Keybinding describes a keyboard shortcut. -type Keybinding struct { - Command string `json:"command"` - Key string `json:"key"` - When string `json:"when,omitempty"` -} - -// Configuration describes contributed settings. -type Configuration struct { - Title string `json:"title"` - Properties map[string]interface{} `json:"properties"` -} - -// DefaultManifest returns the default Hawk IDE extension manifest. -func DefaultManifest() *ExtensionManifest { - return &ExtensionManifest{ - Name: "hawk", - DisplayName: "Hawk AI Agent", - Version: "0.1.0", - Publisher: "graycodeai", - Description: "AI coding agent integration for VS Code", - Main: "./out/extension.js", - Engines: map[string]string{ - "vscode": "^1.74.0", - }, - Categories: []string{"Machine Learning", "Programming Languages"}, - Activation: []string{"onCommand:hawk.start", "onCommand:hawk.explain", "onCommand:hawk.fix"}, - Contributes: &Contributes{ - Commands: []Command{ - {Command: "hawk.start", Title: "Start Hawk Chat", Category: "Hawk"}, - {Command: "hawk.explain", Title: "Explain Selection", Category: "Hawk"}, - {Command: "hawk.fix", Title: "Fix Selection", Category: "Hawk"}, - {Command: "hawk.review", Title: "Review File", Category: "Hawk"}, - {Command: "hawk.test", Title: "Generate Tests", Category: "Hawk"}, - }, - Keybindings: []Keybinding{ - {Command: "hawk.start", Key: "ctrl+shift+h", When: "editorTextFocus"}, - {Command: "hawk.explain", Key: "ctrl+shift+e", When: "editorHasSelection"}, - }, - Configuration: &Configuration{ - Title: "Hawk", - Properties: map[string]interface{}{ - "hawk.apiKey": map[string]interface{}{ - "type": "string", - "default": "", - "description": "API key for the AI provider", - }, - "hawk.provider": map[string]interface{}{ - "type": "string", - "default": "anthropic", - "enum": []string{"anthropic", "openai", "google", "ollama"}, - "description": "AI provider to use", - }, - "hawk.model": map[string]interface{}{ - "type": "string", - "default": "", - "description": "Model to use (leave empty for provider default)", - }, - }, - }, - }, - } -} - -// GeneratePackageJSON generates a package.json for the VSCode extension. -func GeneratePackageJSON() ([]byte, error) { - manifest := DefaultManifest() - return json.MarshalIndent(manifest, "", " ") -} - -// Hints returns IDE integration hints for the current project. -func Hints() []string { - return []string{ - "Install the Hawk VSCode extension for inline AI assistance", - "Use Ctrl+Shift+H to open Hawk chat from VSCode", - "Use Ctrl+Shift+E to explain selected code", - "Configure hawk.provider and hawk.model in VSCode settings", - } -} - -// ServerConfig describes a language server configuration. -type ServerConfig struct { - Command string `json:"command"` - Args []string `json:"args,omitempty"` - Env map[string]string `json:"env,omitempty"` -} - -// SuggestLSPConfig suggests LSP server configurations for common languages. -func SuggestLSPConfig(language string) (*ServerConfig, error) { - configs := map[string]*ServerConfig{ - "go": { - Command: "gopls", - Args: []string{"serve"}, - }, - "typescript": { - Command: "typescript-language-server", - Args: []string{"--stdio"}, - }, - "python": { - Command: "pylsp", - Args: []string{}, - }, - "rust": { - Command: "rust-analyzer", - Args: []string{}, - }, - } - - config, ok := configs[language] - if !ok { - return nil, fmt.Errorf("no LSP config for language: %s", language) - } - return config, nil -} diff --git a/internal/feature/ide/ide_test.go b/internal/feature/ide/ide_test.go deleted file mode 100644 index 253c7339e..000000000 --- a/internal/feature/ide/ide_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package ide - -import "testing" - -func TestDefaultManifest(t *testing.T) { - m := DefaultManifest() - if m.Name != "hawk" { - t.Fatalf("expected name 'hawk', got %q", m.Name) - } - if len(m.Contributes.Commands) == 0 { - t.Fatal("expected commands") - } -} - -func TestGeneratePackageJSON(t *testing.T) { - data, err := GeneratePackageJSON() - if err != nil { - t.Fatal(err) - } - if len(data) == 0 { - t.Fatal("expected non-empty JSON") - } -} - -func TestHints(t *testing.T) { - hints := Hints() - if len(hints) == 0 { - t.Fatal("expected hints") - } -} - -func TestSuggestLSPConfig(t *testing.T) { - config, err := SuggestLSPConfig("go") - if err != nil { - t.Fatal(err) - } - if config.Command != "gopls" { - t.Fatalf("expected gopls, got %q", config.Command) - } - - _, err = SuggestLSPConfig("unknown") - if err == nil { - t.Fatal("expected error for unknown language") - } -} diff --git a/internal/feature/voice/voice.go b/internal/feature/voice/voice.go deleted file mode 100644 index 127cfaa5f..000000000 --- a/internal/feature/voice/voice.go +++ /dev/null @@ -1,85 +0,0 @@ -package voice - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" -) - -// STTConfig holds speech-to-text configuration. -type STTConfig struct { - Engine string `json:"engine"` - Model string `json:"model"` - Lang string `json:"lang"` -} - -// Transcriber handles speech-to-text transcription. -type Transcriber struct { - config STTConfig -} - -// NewTranscriber creates a new transcriber. -func NewTranscriber(config STTConfig) *Transcriber { - return &Transcriber{config: config} -} - -// Transcribe transcribes audio data to text. -func (t *Transcriber) Transcribe(audioData []byte) (string, error) { - // Try whisper.cpp if available - if path, err := exec.LookPath("whisper"); err == nil { - return t.transcribeWhisper(path, audioData) - } - // Fallback to other engines - return "", fmt.Errorf("no STT engine available") -} - -func (t *Transcriber) transcribeWhisper(path string, audioData []byte) (string, error) { - // Write audio to temp file - tmpFile, err := os.CreateTemp("", "hawk-voice-*.wav") - if err != nil { - return "", err - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - defer func() { _ = tmpFile.Close() }() - if _, err := tmpFile.Write(audioData); err != nil { - return "", err - } - - // Run whisper - cmd := exec.CommandContext(context.Background(), path, tmpFile.Name(), "-m", t.config.Model, "-l", t.config.Lang) // #nosec G204 -- path resolved via exec.LookPath("whisper"); tmpFile is our own temp file; config values are local settings - var out bytes.Buffer - cmd.Stdout = &out - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("whisper failed: %w", err) - } - return out.String(), nil -} - -// IsAvailable checks if voice mode is available. -func IsAvailable() bool { - _, err := exec.LookPath("whisper") - return err == nil -} - -// Keyterms returns common voice command keyterms. -func Keyterms() []string { - return []string{ - "hawk", - "run", - "test", - "build", - "fix", - "explain", - "search", - "find", - "edit", - "create", - "delete", - "yes", - "no", - "stop", - "continue", - } -} diff --git a/internal/feature/voice/voice_extra_test.go b/internal/feature/voice/voice_extra_test.go deleted file mode 100644 index b04965556..000000000 --- a/internal/feature/voice/voice_extra_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package voice - -import ( - "strings" - "testing" -) - -func TestNewTranscriber(t *testing.T) { - config := STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - } - transcriber := NewTranscriber(config) - if transcriber == nil { - t.Fatal("expected non-nil transcriber") - } - if transcriber.config.Engine != "whisper" { - t.Errorf("config.Engine = %q, want %q", transcriber.config.Engine, "whisper") - } - if transcriber.config.Model != "base" { - t.Errorf("config.Model = %q, want %q", transcriber.config.Model, "base") - } - if transcriber.config.Lang != "en" { - t.Errorf("config.Lang = %q, want %q", transcriber.config.Lang, "en") - } -} - -func TestNewTranscriber_EmptyConfig(t *testing.T) { - transcriber := NewTranscriber(STTConfig{}) - if transcriber == nil { - t.Fatal("expected non-nil transcriber") - } - if transcriber.config.Engine != "" { - t.Errorf("config.Engine = %q, want empty", transcriber.config.Engine) - } -} - -func TestTranscribe_NoEngineAvailable(t *testing.T) { - transcriber := NewTranscriber(STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - }) - // whisper is not installed, so this should return an error - result, err := transcriber.Transcribe([]byte("fake audio data")) - if err == nil { - t.Error("expected error when no STT engine is available") - } - if !strings.Contains(err.Error(), "no STT engine available") { - t.Errorf("error = %q, want to contain 'no STT engine available'", err.Error()) - } - if result != "" { - t.Errorf("result = %q, want empty", result) - } -} - -func TestTranscribe_EmptyAudioData(t *testing.T) { - transcriber := NewTranscriber(STTConfig{}) - // Even with empty audio data, if no engine is available, should return error - _, err := transcriber.Transcribe([]byte{}) - if err == nil { - t.Error("expected error when no STT engine is available") - } -} - -func TestTranscribe_NilAudioData(t *testing.T) { - transcriber := NewTranscriber(STTConfig{}) - _, err := transcriber.Transcribe(nil) - if err == nil { - t.Error("expected error when no STT engine is available") - } -} - -func TestIsAvailable_NotInstalled(t *testing.T) { - // whisper is not installed in the test environment - available := IsAvailable() - if available { - t.Error("expected IsAvailable() to return false when whisper is not installed") - } -} - -func TestTranscribeWhisper_NonExistentBinary(t *testing.T) { - transcriber := NewTranscriber(STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - }) - // Call transcribeWhisper with a non-existent binary path - // This should fail at exec.CommandContext - result, err := transcriber.transcribeWhisper("/nonexistent/whisper/binary", []byte("audio")) - if err == nil { - t.Error("expected error when whisper binary doesn't exist") - } - if result != "" { - t.Errorf("result = %q, want empty", result) - } -} - -func TestTranscribeWhisper_EmptyAudioData(t *testing.T) { - transcriber := NewTranscriber(STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - }) - // Even with empty audio data, the command will fail since the binary doesn't exist - _, err := transcriber.transcribeWhisper("/nonexistent/whisper/binary", []byte{}) - if err == nil { - t.Error("expected error when whisper binary doesn't exist") - } -} - -func TestSTTConfig_JSONRoundTrip(t *testing.T) { - original := STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - } - // Verify fields are accessible - if original.Engine != "whisper" || original.Model != "base" || original.Lang != "en" { - t.Error("config fields don't match") - } -} - -func TestKeyterms_ContainsExpectedTerms(t *testing.T) { - terms := Keyterms() - expected := []string{"hawk", "run", "test", "build", "yes", "no", "stop"} - for _, e := range expected { - found := false - for _, t := range terms { - if t == e { - found = true - break - } - } - if !found { - t.Errorf("expected keyterm %q not found", e) - } - } -} diff --git a/internal/feature/voice/voice_test.go b/internal/feature/voice/voice_test.go deleted file mode 100644 index 0c5e8f687..000000000 --- a/internal/feature/voice/voice_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package voice - -import "testing" - -func TestKeyterms(t *testing.T) { - terms := Keyterms() - if len(terms) == 0 { - t.Fatal("expected keyterms") - } - seen := make(map[string]bool) - for _, term := range terms { - if seen[term] { - t.Fatalf("duplicate keyterm: %s", term) - } - seen[term] = true - } -} - -func TestSTTConfig(t *testing.T) { - config := STTConfig{ - Engine: "whisper", - Model: "base", - Lang: "en", - } - if config.Engine != "whisper" { - t.Fatal("engine mismatch") - } -} diff --git a/internal/filestate/filestate.go b/internal/filestate/filestate.go deleted file mode 100644 index 9b0f4b649..000000000 --- a/internal/filestate/filestate.go +++ /dev/null @@ -1,313 +0,0 @@ -// Package filestate implements per-turn rewind points over the working tree, -// adopted from grok-build's checkpoint system: at each prompt boundary the -// touched files' contents are captured ("before" snapshots are first-wins, -// "after" snapshots last-write-wins), so a session can be rewound to any -// prior prompt by restoring those snapshots. -// -// A durable disk mirror (atomic temp-file writes, cap-based eviction, -// sanitized session directory names) lets rewind survive process restarts. -package filestate - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" -) - -// ErrNoRewindPoint is returned when no checkpoint exists for an index. -var ErrNoRewindPoint = errors.New("filestate: no rewind point at index") - -const ( - defaultCap = 64 - snapshotHashLen = 16 - storeDirPerms = 0o750 - checkpointFormat = "checkpoint-%d.json" -) - -// Snapshot is the captured content of one file at one moment. -type Snapshot struct { - Path string `json:"path"` - Content string `json:"content"` - Timestamp int64 `json:"timestamp_unix"` -} - -// RewindPoint captures file states around one prompt. -type RewindPoint struct { - PromptIndex int `json:"prompt_index"` - // Before maps path -> content before the first edit of this prompt - // (first-wins within the prompt). - Before map[string]string `json:"before,omitempty"` - // After maps path -> content after the last edit of this prompt - // (last-write-wins). - After map[string]string `json:"after,omitempty"` -} - -// Tracker manages rewind points for one session. -type Tracker struct { - mu sync.Mutex - points map[int]*RewindPoint - current *RewindPoint - order []int // ascending prompt indices present in points - cap int - sessionID string - durable bool -} - -// NewTracker creates a tracker. When durable is true, checkpoints mirror to -// the on-disk store and previously persisted points rehydrate on construction. -func NewTracker(sessionID string, durable bool) (*Tracker, error) { - t := &Tracker{ - points: map[int]*RewindPoint{}, - cap: defaultCap, - sessionID: sanitizeSessionID(sessionID), - durable: durable, - } - if durable { - if err := t.rehydrate(); err != nil { - return nil, err - } - } - return t, nil -} - -// BeginPrompt opens the rewind window for promptIndex. Calling Begin while a -// window is open closes it first. -func (t *Tracker) BeginPrompt(promptIndex int) { - t.mu.Lock() - defer t.mu.Unlock() - _ = t.endLocked() - t.current = &RewindPoint{ - PromptIndex: promptIndex, - Before: map[string]string{}, - After: map[string]string{}, - } -} - -// SetTouchResult records both sides of a touch explicitly: before is kept -// first-wins, after is stored last-wins. This variant avoids double reads. -func (t *Tracker) SetTouchResult(path, beforeContent, afterContent string) { - t.mu.Lock() - defer t.mu.Unlock() - if t.current == nil { - return - } - if _, seen := t.current.Before[path]; !seen { - t.current.Before[path] = beforeContent - } - t.current.After[path] = afterContent -} - -// EndPrompt closes the current window and persists the rewind point. -func (t *Tracker) EndPrompt() error { - t.mu.Lock() - defer t.mu.Unlock() - return t.endLocked() -} - -func (t *Tracker) endLocked() error { - if t.current == nil { - return nil - } - p := t.current - t.current = nil - if len(p.Before) == 0 && len(p.After) == 0 { - return nil // nothing touched: no point worth storing - } - if _, exists := t.points[p.PromptIndex]; !exists { - t.order = append(t.order, p.PromptIndex) - sort.Ints(t.order) - } - t.points[p.PromptIndex] = p - if t.durable && t.sessionID != "" { - if err := t.persist(p); err != nil { - return fmt.Errorf("filestate: persist checkpoint %d: %w", p.PromptIndex, err) - } - } - t.evictLocked() - return nil -} - -// RewindTo returns the restore plan for promptIndex: files whose pre-prompt -// content differs from what is currently on disk, mapped to their restored -// content. It truncates all rewind points at or after promptIndex (a rewind -// discards later history). The caller performs the actual writes. -func (t *Tracker) RewindTo(promptIndex int) (map[string]string, error) { - t.mu.Lock() - defer t.mu.Unlock() - - point, ok := t.points[promptIndex] - if !ok { - return nil, ErrNoRewindPoint - } - plan := map[string]string{} - for path, before := range point.Before { - cur, err := os.ReadFile(path) // #nosec G304 -- recorded workspace path - if err != nil || string(cur) != before { - plan[path] = before - } - } - // Discard this point and everything after it. - for idx := range t.points { - if idx >= promptIndex { - delete(t.points, idx) - } - } - filtered := t.order[:0] - for _, idx := range t.order { - if _, keep := t.points[idx]; keep { - filtered = append(filtered, idx) - } - } - t.order = filtered - if t.durable && t.sessionID != "" { - for idx := range t.pointsOnDisk(promptIndex) { - _ = os.Remove(filepath.Join(t.storeDir(), fmt.Sprintf(checkpointFormat, idx))) - } - } - return plan, nil -} - -// Len reports how many rewind points are held. -func (t *Tracker) Len() int { - t.mu.Lock() - defer t.mu.Unlock() - return len(t.points) -} - -// evictLocked drops the oldest points beyond capacity (memory and disk). -func (t *Tracker) evictLocked() { - for len(t.order) > t.cap { - oldest := t.order[0] - t.order = t.order[1:] - delete(t.points, oldest) - if t.durable && t.sessionID != "" { - _ = os.Remove(filepath.Join(t.storeDir(), fmt.Sprintf(checkpointFormat, oldest))) - } - } -} - -// storeDir is the durable mirror location inside the user state tree. -func (t *Tracker) storeDir() string { - return filepath.Join(stateRoot(), "rewind-checkpoints", t.sessionID) -} - -func (t *Tracker) persist(p *RewindPoint) error { - dir := t.storeDir() - if err := os.MkdirAll(dir, storeDirPerms); err != nil { - return err - } - data, err := json.Marshal(p) - if err != nil { - return err - } - final := filepath.Join(dir, fmt.Sprintf(checkpointFormat, p.PromptIndex)) - tmp := final + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { // #nosec G304 -- sanitized fixed layout - return err - } - return os.Rename(tmp, final) // atomic swap: crash-safe checkpoint -} - -// rehydrate loads persisted checkpoints back into memory. -func (t *Tracker) rehydrate() error { - if t.sessionID == "" { - return nil - } - dir := t.storeDir() - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - for _, e := range entries { - var idx int - if _, err := fmt.Sscanf(e.Name(), checkpointFormat, &idx); err != nil { - continue - } - raw, err := os.ReadFile(filepath.Join(dir, e.Name())) // #nosec G304 -- sanitized dir listing entry - if err != nil { - continue - } - var p RewindPoint - if json.Unmarshal(raw, &p) != nil || p.PromptIndex != idx { - continue - } - if p.Before == nil { - p.Before = map[string]string{} - } - if p.After == nil { - p.After = map[string]string{} - } - if _, exists := t.points[idx]; !exists { - t.order = append(t.order, idx) - t.points[idx] = &p - } - } - sort.Ints(t.order) - return nil -} - -// pointsOnDisk lists persisted checkpoint indexes >= from (for cleanup). -func (t *Tracker) pointsOnDisk(from int) map[int]bool { - out := map[int]bool{} - entries, err := os.ReadDir(t.storeDir()) - if err != nil { - return out - } - for _, e := range entries { - var idx int - if _, err := fmt.Sscanf(e.Name(), checkpointFormat, &idx); err != nil { - continue - } - if idx >= from { - out[idx] = true - } - } - return out -} - -// sanitizeSessionID strips anything but alphanumerics and appends a short -// hash, preventing path traversal from hostile session IDs (grok-build does -// the same for its rewind stores). -func sanitizeSessionID(id string) string { - id = strings.TrimSpace(id) - if id == "" { - return "" - } - var b strings.Builder - for _, r := range id { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': - b.WriteRune(r) - } - } - clean := b.String() - cut := len(clean) - if cut > 24 { - cut = 24 - } - sum := sha256.Sum256([]byte(id)) - return fmt.Sprintf("%s-%s", clean[:cut], hex.EncodeToString(sum[:4])) -} - -// stateRoot mirrors storage.StateDir without importing it (leaf-package rule: -// filestate stays independent of hawk storage layout choices). -func stateRoot() string { - if v := strings.TrimSpace(os.Getenv("HAWK_STATE_DIR")); v != "" { - return v - } - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(os.TempDir(), "hawk", "state") - } - return filepath.Join(home, ".hawk", "state") -} diff --git a/internal/filestate/filestate_test.go b/internal/filestate/filestate_test.go deleted file mode 100644 index ea4a9cf8b..000000000 --- a/internal/filestate/filestate_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package filestate - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRewindBasicFlow(t *testing.T) { - state := t.TempDir() - t.Setenv("HAWK_STATE_DIR", state) - work := t.TempDir() - f := filepath.Join(work, "code.go") - if err := os.WriteFile(f, []byte("v1\n"), 0o644); err != nil { - t.Fatal(err) - } - - tr, err := NewTracker("sess-1", false) - if err != nil { - t.Fatal(err) - } - tr.BeginPrompt(0) - tr.SetTouchResult(f, "v1\n", "v2\n") - if err := os.WriteFile(f, []byte("v2\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := tr.EndPrompt(); err != nil { - t.Fatal(err) - } - if tr.Len() != 1 { - t.Fatalf("points = %d", tr.Len()) - } - - // Prompt 1 edits again. - tr.BeginPrompt(1) - tr.SetTouchResult(f, "v2\n", "v3\n") - _ = os.WriteFile(f, []byte("v3\n"), 0o644) - _ = tr.EndPrompt() - - // Rewind to prompt 0: plan restores v1. - plan, err := tr.RewindTo(0) - if err != nil { - t.Fatal(err) - } - if plan[f] != "v1\n" { - t.Fatalf("plan = %q", plan[f]) - } - // Later history truncated. - if tr.Len() != 0 { - t.Fatalf("len after rewind = %d", tr.Len()) - } -} - -func TestRewindSkipsUnchangedFiles(t *testing.T) { - state := t.TempDir() - t.Setenv("HAWK_STATE_DIR", state) - work := t.TempDir() - f := filepath.Join(work, "a.txt") - _ = os.WriteFile(f, []byte("same"), 0o644) - - tr, _ := NewTracker("s", false) - tr.BeginPrompt(0) - tr.SetTouchResult(f, "same", "same") // touched but unchanged - _ = tr.EndPrompt() - - plan, err := tr.RewindTo(0) - if err != nil { - t.Fatal(err) - } - if len(plan) != 0 { - t.Fatalf("plan should skip unchanged files: %v", plan) - } -} - -func TestDurableRehydration(t *testing.T) { - state := t.TempDir() - t.Setenv("HAWK_STATE_DIR", state) - work := t.TempDir() - f := filepath.Join(work, "x.txt") - _ = os.WriteFile(f, []byte("one"), 0o644) - - tr, err := NewTracker("durable-session", true) - if err != nil { - t.Fatal(err) - } - tr.BeginPrompt(7) - tr.SetTouchResult(f, "one", "two") - _ = os.WriteFile(f, []byte("two"), 0o644) - if err := tr.EndPrompt(); err != nil { - t.Fatal(err) - } - - // A brand-new tracker (process restart) rehydrates from disk. - tr2, err := NewTracker("durable-session", true) - if err != nil { - t.Fatal(err) - } - if tr2.Len() != 1 { - t.Fatalf("rehydrated points = %d", tr2.Len()) - } - plan, err := tr2.RewindTo(7) - if err != nil { - t.Fatal(err) - } - if plan[f] != "one" { - t.Fatalf("plan = %q", plan[f]) - } -} - -func TestSanitizeSessionID(t *testing.T) { - hostile := "../../evil/../../id with spaces!" - sanitized := sanitizeSessionID(hostile) - if strings.Contains(sanitized, "..") || strings.Contains(sanitized, "/") || - strings.Contains(sanitized, " ") { - t.Fatalf("sanitized leaks traversal: %q", sanitized) - } - if sanitizeSessionID("") != "" { - t.Fatal("empty id must stay empty") - } - // Same input -> same output (stable store dirs). - if sanitizeSessionID(hostile) != sanitized { - t.Fatal("sanitize not deterministic") - } -} - -func TestEvictionCap(t *testing.T) { - state := t.TempDir() - t.Setenv("HAWK_STATE_DIR", state) - tr, _ := NewTracker("cap-sess", false) - for i := 0; i < defaultCap+10; i++ { - tr.BeginPrompt(i) - tr.SetTouchResult("/unused", "a", "b") - if err := tr.EndPrompt(); err != nil { - t.Fatal(err) - } - } - if tr.Len() != defaultCap { - t.Fatalf("len = %d, want cap %d", tr.Len(), defaultCap) - } - // Oldest points evicted: index 0 no longer available. - if _, err := tr.RewindTo(0); err == nil { - t.Fatal("expected ErrNoRewindPoint for evicted index") - } else if err != ErrNoRewindPoint { - t.Fatalf("err = %v", err) - } - // Newest point still available. - if _, err := tr.RewindTo(defaultCap + 9); err != nil { - t.Fatalf("newest point missing: %v", err) - } -} - -func TestNoTouchNoPoint(t *testing.T) { - tr, _ := NewTracker("empty", false) - tr.BeginPrompt(0) - if err := tr.EndPrompt(); err != nil { - t.Fatal(err) - } - if tr.Len() != 0 { - t.Fatalf("empty window stored a point: %d", tr.Len()) - } -} - -func TestRewindUnknownIndex(t *testing.T) { - tr, _ := NewTracker("none", false) - if _, err := tr.RewindTo(42); err != ErrNoRewindPoint { - t.Fatalf("err = %v", err) - } -} diff --git a/internal/gitsnapshot/snapshot.go b/internal/gitsnapshot/snapshot.go deleted file mode 100644 index d7763442c..000000000 --- a/internal/gitsnapshot/snapshot.go +++ /dev/null @@ -1,371 +0,0 @@ -// Package gitsnapshot captures point-in-time file-tree snapshots using git's -// content-addressed object database, adopting the approach opencode uses for -// durable, cheap snapshots. -// -// A Manager keeps a linked snapshot repository whose object database is seeded -// from the source repository via objects/info/alternates, so capturing a tree -// reuses hashes already computed by the source repo instead of re-hashing large -// checkouts. Snapshots are content-addressed git tree IDs, which makes diffing, -// previewing, and restoring cheap and unambiguous. -package gitsnapshot - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// TreeID is a content-addressed git tree hash. -type TreeID string - -// StaleContentError reports a compare-and-swap write that would clobber a file -// changed by another writer since it was read. -type StaleContentError struct { - Path string -} - -func (e *StaleContentError) Error() string { - return "gitsnapshot: stale content at " + e.Path + " (file changed since read)" -} - -// FileChange describes a single file's change between two trees. -type FileChange struct { - Path string - Status string // "added", "modified", "deleted" - Additions int - Deletions int - Patch string -} - -// Manager snapshots a source repository's file tree into a linked snapshot repo. -type Manager struct { - // SourceDir is the working directory of the source repo. - SourceDir string - // SnapDir is the directory holding the linked snapshot repo. - SnapDir string -} - -// New creates a Manager and prepares the linked snapshot repo, seeding its -// object storage from the source repo. SourceDir must be inside a git repo. -func New(sourceDir, snapDir string) (*Manager, error) { - abs, err := filepath.Abs(sourceDir) - if err != nil { - return nil, fmt.Errorf("gitsnapshot: resolve source dir: %w", err) - } - m := &Manager{SourceDir: abs, SnapDir: snapDir} - if err := m.initLinkedRepo(); err != nil { - return nil, err - } - return m, nil -} - -// initLinkedRepo creates the snapshot repo (if needed) and wires its object -// database to the source repo via objects/info/alternates. -func (m *Manager) initLinkedRepo() error { - if m.SnapDir == "" { - return fmt.Errorf("gitsnapshot: empty snapshot dir") - } - if err := os.MkdirAll(m.SnapDir, 0o750); err != nil { - return fmt.Errorf("gitsnapshot: create snapshot dir: %w", err) - } - gitDir := filepath.Join(m.SnapDir, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - if err := m.gitRun(m.SnapDir, "init", "--bare"); err != nil { - return fmt.Errorf("gitsnapshot: init linked repo: %w", err) - } - } - - // Point the snapshot repo's object db at the source repo's object db. - altFile := filepath.Join(gitDir, "objects", "info", "alternates") - srcObjDir, err := m.sourceObjectDir() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(altFile), 0o750); err != nil { - return fmt.Errorf("gitsnapshot: mkdir objects/info: %w", err) - } - if err := os.WriteFile(altFile, []byte(srcObjDir+"\n"), 0o600); err != nil { - return fmt.Errorf("gitsnapshot: write alternates: %w", err) - } - return nil -} - -// sourceObjectDir returns the absolute path to the source repo's object db. -func (m *Manager) sourceObjectDir() (string, error) { - out, err := m.gitOut(m.SourceDir, "rev-parse", "--git-dir") - if err != nil { - return "", fmt.Errorf("gitsnapshot: locate source git dir: %w", err) - } - gitDir := strings.TrimSpace(out) - if !filepath.IsAbs(gitDir) { - gitDir = filepath.Join(m.SourceDir, gitDir) - } - objDir := filepath.Join(gitDir, "objects") - if st, err := os.Stat(objDir); err != nil || !st.IsDir() { - return "", fmt.Errorf("gitsnapshot: source object db not found at %s", objDir) - } - return objDir, nil -} - -// Capture stages the given project-relative paths into a private throwaway -// index in the source repo and writes a content-addressed tree, returning its -// TreeID. The user's real index and staging area are left untouched. An empty -// paths list captures the whole worktree (respecting .gitignore). The resulting -// tree lives in the source object db, which the linked snapshot repo shares. -func (m *Manager) Capture(ctx context.Context, paths []string) (TreeID, error) { - idx, err := os.CreateTemp("", "gitsnapshot-capture-*.index") - if err != nil { - return "", fmt.Errorf("gitsnapshot: create capture index: %w", err) - } - idxPath := idx.Name() - _ = idx.Close() - defer func() { _ = os.Remove(idxPath) }() - - env := append(os.Environ(), "GIT_INDEX_FILE="+idxPath) - - // Seed the throwaway index from HEAD so tree reads/writes reflect the repo. - if _, err := m.gitOutEnv(ctx, m.SourceDir, env, "read-tree", "HEAD"); err != nil { - // A repo with no commits has no HEAD; tolerate by starting empty. - if _, e2 := m.gitOutEnv(ctx, m.SourceDir, env, "read-tree", "--empty"); e2 != nil { - return "", fmt.Errorf("gitsnapshot: seed index: %w", err) - } - } - - args := []string{"add", "--all"} - if len(paths) > 0 { - args = append(args, "--") - args = append(args, paths...) - } - if _, err := m.gitOutEnv(ctx, m.SourceDir, env, args...); err != nil { - return "", fmt.Errorf("gitsnapshot: add: %w", err) - } - out, err := m.gitOutEnv(ctx, m.SourceDir, env, "write-tree") - if err != nil { - return "", fmt.Errorf("gitsnapshot: write-tree: %w", err) - } - tid := TreeID(strings.TrimSpace(out)) - if tid == "" { - return "", fmt.Errorf("gitsnapshot: write-tree produced empty id") - } - return tid, nil -} - -// Diff returns per-file changes between two trees. Either id may be empty to -// compare against an empty tree (all files added). -func (m *Manager) Diff(ctx context.Context, from, to TreeID) ([]FileChange, error) { - a, b := string(from), string(to) - if a == "" { - a = emptyTreeID(ctx, m) - } - if b == "" { - b = emptyTreeID(ctx, m) - } - raw, err := m.gitOutCtx(ctx, m.SourceDir, "diff-tree", "-r", "--name-status", a, b) - if err != nil { - return nil, fmt.Errorf("gitsnapshot: diff-tree: %w", err) - } - var changes []FileChange - for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - status := fields[0] - path := fields[len(fields)-1] - ch := FileChange{Path: path} - switch status[0] { - case 'A': - ch.Status = "added" - case 'D': - ch.Status = "deleted" - case 'M': - ch.Status = "modified" - default: - ch.Status = status - } - changes = append(changes, ch) - } - return changes, nil -} - -// Preview computes a hypothetical per-file diff for a set of paths against a -// target tree without touching the source worktree. It builds a throwaway -// index (GIT_INDEX_FILE) so the operation is side-effect free. -func (m *Manager) Preview(ctx context.Context, target TreeID, paths []string) ([]FileChange, error) { - tmp, err := os.CreateTemp("", "gitsnapshot-preview-*.index") - if err != nil { - return nil, fmt.Errorf("gitsnapshot: create throwaway index: %w", err) - } - idxPath := tmp.Name() - if err := tmp.Close(); err != nil { - return nil, err - } - defer func() { _ = os.Remove(idxPath) }() - - env := append(os.Environ(), "GIT_INDEX_FILE="+idxPath) - if _, err := m.gitOutEnv(ctx, m.SourceDir, env, "read-tree", string(target)); err != nil { - return nil, fmt.Errorf("gitsnapshot: read-tree: %w", err) - } - - var changes []FileChange - for _, p := range paths { - // Compare the index blob for the path against the worktree file. - idxBlob, err := m.gitOutEnv(ctx, m.SourceDir, env, "ls-files", "-s", "--", p) - if err != nil { - return nil, err - } - worktreePath := filepath.Join(m.SourceDir, p) - data, err := os.ReadFile(worktreePath) - if err != nil { - if os.IsNotExist(err) { - changes = append(changes, FileChange{Path: p, Status: "deleted"}) - continue - } - return nil, err - } - // Hash the worktree blob and compare to the index entry hash. - idxFields := strings.Fields(idxBlob) - blobID := "" - if len(idxFields) >= 2 { - // "ls-files -s" emits " \t". - blobID = idxFields[1] - } - if blobID == "" { - changes = append(changes, FileChange{Path: p, Status: "added"}) - continue - } - workID := m.hashBlob(ctx, env, data) - if workID == "" { - changes = append(changes, FileChange{Path: p, Status: "modified"}) - continue - } - if workID != blobID { - changes = append(changes, FileChange{Path: p, Status: "modified"}) - } - } - return changes, nil -} - -// Restore selectively checks out (or deletes) paths from a target tree into the -// source worktree. Deleted-in-tree paths are removed from the worktree. -func (m *Manager) Restore(ctx context.Context, target TreeID, paths []string) error { - for _, p := range paths { - worktreePath := filepath.Join(m.SourceDir, p) - blobID, err := m.gitOutCtx(ctx, m.SourceDir, "ls-tree", string(target), "--", p) - if err != nil { - return err - } - trimmed := strings.TrimSpace(blobID) - if trimmed == "" { - // Path absent from tree => delete from worktree. - if err := os.RemoveAll(worktreePath); err != nil { - return fmt.Errorf("gitsnapshot: delete %s: %w", p, err) - } - continue - } - // blobID line: " blob \t" - fields := strings.Fields(trimmed) - if len(fields) < 3 { - return fmt.Errorf("gitsnapshot: unexpected ls-tree output for %s: %q", p, trimmed) - } - if err := m.writeBlobToFile(ctx, envPlain(), fields[2], worktreePath); err != nil { - return err - } - } - return nil -} - -// WriteIfUnchanged performs a compare-and-swap write: it only writes newBytes -// to path if the current content still equals expectedBytes. It returns a -// *StaleContentError when the file changed since it was read, preventing one -// agent from clobbering another's concurrent edit. -func (m *Manager) WriteIfUnchanged(path string, expected, newContent []byte) error { - current, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("gitsnapshot: read %s: %w", path, err) - } - if !bytes.Equal(current, expected) { - return &StaleContentError{Path: path} - } - if err := os.WriteFile(path, newContent, 0o644); err != nil { - return fmt.Errorf("gitsnapshot: write %s: %w", path, err) - } - return nil -} - -// writeBlobToFile writes a git blob's content to path using `git cat-file`. -func (m *Manager) writeBlobToFile(ctx context.Context, env []string, blobID, path string) error { - cmd := exec.CommandContext(ctx, "git", "-C", m.SourceDir, "cat-file", "blob", blobID) // #nosec G204 -- fixed git subcommand, internally-derived blob id - cmd.Env = env - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("gitsnapshot: cat-file %s: %w", blobID, err) - } - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - return err - } - return os.WriteFile(path, out, 0o644) -} - -func (m *Manager) hashBlob(ctx context.Context, env []string, data []byte) string { - cmd := exec.CommandContext(ctx, "git", "-C", m.SourceDir, "hash-object", "-t", "blob", "--stdin") // #nosec G204 - cmd.Env = env - cmd.Stdin = bytes.NewReader(data) - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func (m *Manager) gitRun(dir string, args ...string) error { - return m.gitRunCtx(context.Background(), dir, args...) -} - -func (m *Manager) gitRunCtx(ctx context.Context, dir string, args ...string) error { - full := append([]string{"-C", dir}, args...) - cmd := exec.CommandContext(ctx, "git", full...) // #nosec G204 -- fixed git subcommand, internally-derived args - cmd.Env = envPlain() - return cmd.Run() -} - -func (m *Manager) gitOut(dir string, args ...string) (string, error) { - return m.gitOutCtx(context.Background(), dir, args...) -} - -func (m *Manager) gitOutCtx(ctx context.Context, dir string, args ...string) (string, error) { - return m.gitOutEnv(ctx, dir, envPlain(), args...) -} - -func (m *Manager) gitOutEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) { - full := append([]string{"-C", dir}, args...) - cmd := exec.CommandContext(ctx, "git", full...) // #nosec G204 -- fixed git subcommand, internally-derived args - cmd.Env = env - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) - } - return string(out), nil -} - -// envPlain returns the current environment (used to keep PATH etc.). -func envPlain() []string { - return os.Environ() -} - -// emptyTreeID returns the well-known empty tree hash. This is the git -// object id of the empty tree, stable across all git installations. -const emptyTreeIDValue = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" - -func emptyTreeID(ctx context.Context, m *Manager) string { - _ = ctx - _ = m - return emptyTreeIDValue -} diff --git a/internal/gitsnapshot/snapshot_test.go b/internal/gitsnapshot/snapshot_test.go deleted file mode 100644 index 4cb684a98..000000000 --- a/internal/gitsnapshot/snapshot_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package gitsnapshot - -import ( - "bytes" - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// runGit is a test helper that runs git in a directory. -func runGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } - return strings.TrimSpace(string(out)) -} - -// setupRepo creates a temp git repo with some committed files and returns its dir. -func setupRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - runGit(t, dir, "init", "-q") - runGit(t, dir, "config", "user.email", "test@example.com") - runGit(t, dir, "config", "user.name", "Test") - if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("alpha\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("beta\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", ".") - runGit(t, dir, "commit", "-qm", "init") - return dir -} - -func TestNewCreatesLinkedRepo(t *testing.T) { - src := setupRepo(t) - snap := filepath.Join(t.TempDir(), "snap") - m, err := New(src, snap) - if err != nil { - t.Fatalf("New: %v", err) - } - if _, err := os.Stat(filepath.Join(snap, ".git", "objects", "info", "alternates")); err != nil { - t.Fatalf("alternates file missing: %v", err) - } - // The alternates file must reference the source object db. - b, err := os.ReadFile(filepath.Join(snap, ".git", "objects", "info", "alternates")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(b), "objects") { - t.Fatalf("alternates = %q", string(b)) - } - _ = m -} - -func TestCaptureReturnsTreeID(t *testing.T) { - src := setupRepo(t) - m, err := New(src, filepath.Join(t.TempDir(), "snap")) - if err != nil { - t.Fatal(err) - } - tid, err := m.Capture(context.Background(), nil) - if err != nil { - t.Fatalf("Capture: %v", err) - } - if tid == "" { - t.Fatal("empty tree id") - } -} - -func TestDiffDetectsChanges(t *testing.T) { - src := setupRepo(t) - m, _ := New(src, filepath.Join(t.TempDir(), "snap")) - ctx := context.Background() - - base, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - - // Modify a.txt and add c.txt. - if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("alpha2\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(src, "c.txt"), []byte("charlie\n"), 0o644); err != nil { - t.Fatal(err) - } - now, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - - changes, err := m.Diff(ctx, base, now) - if err != nil { - t.Fatalf("Diff: %v", err) - } - byPath := map[string]string{} - for _, c := range changes { - byPath[c.Path] = c.Status - } - if byPath["a.txt"] != "modified" { - t.Fatalf("a.txt status = %q, want modified; changes=%+v", byPath["a.txt"], changes) - } - if byPath["c.txt"] != "added" { - t.Fatalf("c.txt status = %q, want added", byPath["c.txt"]) - } -} - -func TestWriteIfUnchangedSuccessAndStale(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "f.txt") - if err := os.WriteFile(path, []byte("expected"), 0o644); err != nil { - t.Fatal(err) - } - m := &Manager{} - if err := m.WriteIfUnchanged(path, []byte("expected"), []byte("new")); err != nil { - t.Fatalf("WriteIfUnchanged: %v", err) - } - got, _ := os.ReadFile(path) - if !bytes.Equal(got, []byte("new")) { - t.Fatalf("content = %q, want new", got) - } - - // Now the file is "new"; writing with stale expected must fail. - err := m.WriteIfUnchanged(path, []byte("expected"), []byte("other")) - if err == nil { - t.Fatal("expected stale content error") - } - if _, ok := err.(*StaleContentError); !ok { - t.Fatalf("err = %T, want *StaleContentError", err) - } -} - -func TestRestoreChecksOutAndDeletes(t *testing.T) { - src := setupRepo(t) - m, _ := New(src, filepath.Join(t.TempDir(), "snap")) - ctx := context.Background() - - base, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - // Modify and capture a new tree. - if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("changed\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(src, "c.txt"), []byte("new\n"), 0o644); err != nil { - t.Fatal(err) - } - now, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - - // Restore a.txt from base (revert the change) and delete c.txt (absent in base). - if err := m.Restore(ctx, base, []string{"a.txt", "c.txt"}); err != nil { - t.Fatalf("Restore: %v", err) - } - got, _ := os.ReadFile(filepath.Join(src, "a.txt")) - if string(got) != "alpha\n" { - t.Fatalf("a.txt = %q, want alpha", got) - } - if _, err := os.Stat(filepath.Join(src, "c.txt")); !os.IsNotExist(err) { - t.Fatalf("c.txt should be deleted, got %v", err) - } - _ = now -} - -func TestPreviewDetectsModificationWithoutTouchingWorktree(t *testing.T) { - src := setupRepo(t) - m, _ := New(src, filepath.Join(t.TempDir(), "snap")) - ctx := context.Background() - - base, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - - // Modify a.txt after capturing the baseline. - if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("modified\n"), 0o644); err != nil { - t.Fatal(err) - } - - // Preview the worktree against the baseline for a.txt only. - changes, err := m.Preview(ctx, base, []string{"a.txt"}) - if err != nil { - t.Fatalf("Preview: %v", err) - } - if len(changes) != 1 || changes[0].Path != "a.txt" || changes[0].Status != "modified" { - t.Fatalf("changes = %+v, want single modified a.txt", changes) - } - - // The worktree file must be untouched by the preview. - got, _ := os.ReadFile(filepath.Join(src, "a.txt")) - if string(got) != "modified\n" { - t.Fatalf("preview mutated worktree: %q", got) - } -} - -func TestPreviewNoChangeWhenFileMatchesTree(t *testing.T) { - src := setupRepo(t) - m, _ := New(src, filepath.Join(t.TempDir(), "snap")) - ctx := context.Background() - - base, err := m.Capture(ctx, nil) - if err != nil { - t.Fatal(err) - } - changes, err := m.Preview(ctx, base, []string{"a.txt", "b.txt"}) - if err != nil { - t.Fatalf("Preview: %v", err) - } - if len(changes) != 0 { - t.Fatalf("expected no changes, got %+v", changes) - } -} diff --git a/internal/hunktracker/hunktracker.go b/internal/hunktracker/hunktracker.go deleted file mode 100644 index 7062e325e..000000000 --- a/internal/hunktracker/hunktracker.go +++ /dev/null @@ -1,235 +0,0 @@ -// Package hunktracker tracks line-level edits ("hunks") per file with stable -// hunk identity across re-computation and author attribution that survives -// external edits, adopted from grok-build's xai-hunk-tracker. -// -// The core problem: naive re-diffing after every edit assigns fresh positions -// and IDs, so "the change the agent made" cannot be followed across subsequent -// modifications. Here each hunk carries an ID derived from its content, and -// when file content changes later, previously tracked hunks are matched by -// content-and-overlap so their identity — and their author attribution — is -// preserved even when the newest edit came from someone else. -package hunktracker - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "sort" - "strings" -) - -// Author classifies who produced a hunk. -type Author string - -const ( - // AuthorAgent marks hunks written by the coding agent. - AuthorAgent Author = "agent" - // AuthorExternal marks hunks written by anything else (user, other tools). - AuthorExternal Author = "external" -) - -// Hunk is one contiguous changed region. -type Hunk struct { - // ID is stable across re-computation while content overlaps. - ID string `json:"id"` - // StartLine is 1-based in the *current* content. - StartLine int `json:"start_line"` - Lines int `json:"lines"` - Text string `json:"text"` - // Author records who last wrote this region. - Author Author `json:"author"` -} - -// FileState is the tracked state of one file. -type FileState struct { - Path string `json:"path"` - Baseline string `json:"baseline"` - Hunks []Hunk `json:"hunks"` -} - -// Tracker holds per-file hunk states. -type Tracker struct { - files map[string]*FileState -} - -// NewTracker returns an empty tracker. -func NewTracker() *Tracker { return &Tracker{files: map[string]*FileState{}} } - -// Track registers a file whose future changes should be attributed. The -// current content becomes the baseline. -func (t *Tracker) Track(path, currentContent string) { - t.files[path] = &FileState{Path: path, Baseline: currentContent} -} - -// Forget removes a file from tracking. -func (t *Tracker) Forget(path string) { delete(t.files, path) } - -// Tracked reports whether path is being tracked. -func (t *Tracker) Tracked(path string) bool { _, ok := t.files[path]; return ok } - -// Update records an edit of a tracked file and returns its hunks after -// identity reconciliation. Untracked paths are ignored (call Track first). -func (t *Tracker) Update(path, newContent string, author Author) ([]Hunk, bool) { - st, ok := t.files[path] - if !ok { - return nil, false - } - newHunks := computeHunks(st.Baseline, newContent) - st.Hunks = reconcile(st.Hunks, newHunks, author) - st.Baseline = newContent - return st.Hunks, true -} - -// Hunks returns a copy of the tracked hunks for a file. -func (t *Tracker) Hunks(path string) []Hunk { - st, ok := t.files[path] - if !ok { - return nil - } - out := make([]Hunk, len(st.Hunks)) - copy(out, st.Hunks) - return out -} - -// AgentTouchedFiles lists tracked files with at least one agent-authored hunk. -func (t *Tracker) AgentTouchedFiles() []string { - var out []string - for p, st := range t.files { - for _, h := range st.Hunks { - if h.Author == AuthorAgent { - out = append(out, p) - break - } - } - } - sort.Strings(out) - return out -} - -// computeHunks diffs baseline vs current line-wise, returning changed regions -// positioned in current. It uses an LCS over lines. -func computeHunks(baseline, current string) []Hunk { - a := splitLines(baseline) - b := splitLines(current) - ops := lcsOps(a, b) // sequence of ops over b indices: keep/delete(insert) - - var hunks []Hunk - i := 0 - for i < len(ops) { - if ops[i] { - // Kept line (part of the LCS with baseline): not a change. - i++ - continue - } - start := i - for i < len(ops) && !ops[i] { - i++ - } - h := Hunk{ - ID: hunkID(b[start:i]), - StartLine: start + 1, - Lines: i - start, - Text: strings.Join(b[start:i], "\n"), - } - hunks = append(hunks, h) - } - return hunks -} - -// reconcile merges newly computed hunks with previously known ones: -// - a new hunk matching an old hunk's ID keeps the old identity and author; -// - a new hunk overlapping an old one keeps the old author (an external edit -// touching an agent-authored region does not strip attribution); -// - brand-new hunks take the incoming author. -func reconcile(old, next []Hunk, author Author) []Hunk { - oldByID := map[string]Hunk{} - for _, h := range old { - oldByID[h.ID] = h - } - out := make([]Hunk, 0, len(next)) - for _, h := range next { - if prev, ok := oldByID[h.ID]; ok { - h.Author = prev.Author - out = append(out, h) - continue - } - attributed := Author("") - for _, prev := range old { - if hunksOverlap(prev, h) { - attributed = prev.Author - break - } - } - if attributed == "" { - attributed = author - } - // Note: an external edit overlapping a previously agent-authored - // region intentionally keeps the prior attribution. - h.Author = attributed - out = append(out, h) - } - return out -} - -func hunksOverlap(a, b Hunk) bool { - aStart, aEnd := a.StartLine, a.StartLine+a.Lines - bStart, bEnd := b.StartLine, b.StartLine+b.Lines - return aStart < bEnd && bStart < aEnd -} - -func hunkID(lines []string) string { - sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) - return fmt.Sprintf("h%s", hex.EncodeToString(sum[:6])) -} - -func splitLines(s string) []string { - s = strings.TrimSuffix(s, "\n") - if s == "" { - return nil - } - return strings.Split(s, "\n") -} - -// lcsOps returns, for each index of b, whether that line is part of the LCS -// with a (true = kept/present in both, false = inserted relative to a). -func lcsOps(a, b []string) []bool { - n, m := len(a), len(b) - // Guard against pathological sizes; fall back to "all inserted". - if n*m > 4_000_000 { - all := make([]bool, m) - for i := range all { - all[i] = true - } - return all - } - dp := make([][]int, n+1) - for i := range dp { - dp[i] = make([]int, m+1) - } - for i := n - 1; i >= 0; i-- { - for j := m - 1; j >= 0; j-- { - if a[i] == b[j] { - dp[i][j] = dp[i+1][j+1] + 1 - } else if dp[i+1][j] >= dp[i][j+1] { - dp[i][j] = dp[i+1][j] - } else { - dp[i][j] = dp[i][j+1] - } - } - } - ops := make([]bool, m) // true = line kept from baseline (LCS member) - i, j := 0, 0 - for i < n && j < m { - switch { - case a[i] == b[j]: - ops[j] = true - i++ - j++ - case dp[i+1][j] >= dp[i][j+1]: - i++ - default: - j++ - } - } - return ops -} diff --git a/internal/hunktracker/hunktracker_test.go b/internal/hunktracker/hunktracker_test.go deleted file mode 100644 index 7113e0b78..000000000 --- a/internal/hunktracker/hunktracker_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package hunktracker - -import ( - "testing" -) - -func TestComputeHunksBasic(t *testing.T) { - base := "a\nb\nc\nd\ne\n" - cur := "a\nb\nX\nY\nd\ne\n" - hunks := computeHunks(base, cur) - if len(hunks) != 1 { - t.Fatalf("hunks = %+v", hunks) - } - h := hunks[0] - if h.StartLine != 3 || h.Lines != 2 || h.Text != "X\nY" { - t.Fatalf("hunk = %+v", h) - } -} - -func TestTrackerAttributionAndIdentity(t *testing.T) { - tr := NewTracker() - base := "line1\nline2\nline3\n" - tr.Track("f.go", base) - - hs, ok := tr.Update("f.go", "line1\nAGENT\nline3\n", AuthorAgent) - if !ok || len(hs) != 1 || hs[0].Author != AuthorAgent { - t.Fatalf("agent update: %+v ok=%v", hs, ok) - } - - // External edit adjacent to the agent region: the new change hunk overlaps - // the previously agent-authored position, so attribution stays "agent". - hs, _ = tr.Update("f.go", "line1\nTOP\nAGENT\nline3\n", AuthorExternal) - found := false - for _, h := range hs { - if h.StartLine == 2 && h.Lines >= 1 { - found = true - if h.Author != AuthorAgent { - t.Fatalf("agent attribution lost after external edit: %+v", hs) - } - } - } - if !found { - t.Fatalf("expected an overlapping hunk near line 2: %+v", hs) - } -} - -func TestTrackerUntrackedIgnored(t *testing.T) { - tr := NewTracker() - if hs, ok := tr.Update("nope", "x", AuthorAgent); ok || hs != nil { - t.Fatal("untracked file must be ignored") - } -} - -func TestAgentTouchedFiles(t *testing.T) { - tr := NewTracker() - tr.Track("a.go", "1\n") - tr.Track("b.go", "2\n") - if _, ok := tr.Update("a.go", "1\nx\n", AuthorAgent); !ok { - t.Fatal("update a failed") - } - if _, ok := tr.Update("b.go", "2\ny\n", AuthorExternal); !ok { - t.Fatal("update b failed") - } - got := tr.AgentTouchedFiles() - if len(got) != 1 || got[0] != "a.go" { - t.Fatalf("AgentTouchedFiles = %v", got) - } -} - -func TestHunkIDDeterministic(t *testing.T) { - // Identical (baseline, current) pairs must yield identical IDs. - a := computeHunks("x\ny\n", "x\nNEW\ny\n") - b := computeHunks("x\ny\n", "x\nNEW\ny\n") - if len(a) != 1 || len(b) != 1 || a[0].ID != b[0].ID { - t.Fatalf("IDs differ for identical diffs: %v vs %v", a, b) - } -} - -func TestComputeHunksEmptyBaseline(t *testing.T) { - hunks := computeHunks("", "one\ntwo\n") - if len(hunks) != 1 || hunks[0].StartLine != 1 || hunks[0].Lines != 2 { - t.Fatalf("hunks = %+v", hunks) - } -} - -func TestComputeHunksNoChange(t *testing.T) { - s := "a\nb\n" - if hunks := computeHunks(s, s); len(hunks) != 0 { - t.Fatalf("expected no hunks, got %+v", hunks) - } -} diff --git a/internal/intelligence/harness/harness.go b/internal/intelligence/harness/harness.go deleted file mode 100644 index 5aadd76ec..000000000 --- a/internal/intelligence/harness/harness.go +++ /dev/null @@ -1,322 +0,0 @@ -// Package harness implements a continual, evidence-backed refinement store -// adopted from Prime Agent's Continual Harness: supplemental agent state -// (prompts, memories, skill descriptions, subagent specs) is stored as -// versioned entries that the agent can refine through small, evidence-backed -// updates, with a recorded refinement history and snapshot-based rollback. -// -// It never rewrites the immutable base system prompt — only this supplemental -// state. Entries are keyed by kind; each update bumps the entry version and -// appends a refinement event (trigger -> changes -> evidence -> outcome) so -// every change is auditable and reversible. -package harness - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// Kind is the category of supplemental state. -type Kind string - -const ( - KindPrompt Kind = "prompt" - KindMemory Kind = "memory" - KindSkill Kind = "skill" - KindSubagent Kind = "subagent" -) - -func (k Kind) Valid() bool { - switch k { - case KindPrompt, KindMemory, KindSkill, KindSubagent: - return true - } - return false -} - -// Scope is where an entry applies. -type Scope string - -const ( - ScopeLocal Scope = "local" - ScopeGlobal Scope = "global" -) - -// Entry is one versioned supplemental state item. -type Entry struct { - ID string `json:"id"` - Kind Kind `json:"kind"` - Title string `json:"title"` - Content string `json:"content"` - Path string `json:"path,omitempty"` - Scope Scope `json:"scope,omitempty"` - Version int `json:"version"` - Evidence string `json:"evidence,omitempty"` - Source string `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Refinement is one recorded update event. -type Refinement struct { - ID string `json:"id"` - Trigger string `json:"trigger"` - Changes []string `json:"changes"` - Evidence string `json:"evidence"` - Outcome string `json:"outcome,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// Snapshot is a point-in-time copy of all entries, used for rollback. -type Snapshot struct { - Schema int `json:"schema"` - Entries []Entry `json:"entries"` -} - -// Store is a thread-safe continual harness. -type Store struct { - mu sync.Mutex - entries map[Kind]map[string]*Entry - refinements []Refinement - dir string -} - -// New creates a Store persisted under dir ("" disables persistence). -func New(dir string) (*Store, error) { - s := &Store{ - entries: map[Kind]map[string]*Entry{ - KindPrompt: {}, KindMemory: {}, KindSkill: {}, KindSubagent: {}, - }, - dir: dir, - } - if dir != "" { - if err := s.load(); err != nil { - return nil, err - } - } - return s, nil -} - -func (s *Store) path() string { - if s.dir == "" { - return "" - } - return filepath.Join(s.dir, "harness.json") -} - -func (s *Store) load() error { - raw, err := os.ReadFile(s.path()) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("harness: load: %w", err) - } - var st struct { - Schema int `json:"schema"` - Entries []Entry `json:"entries"` - History []Refinement `json:"history"` - } - if err := json.Unmarshal(raw, &st); err != nil { - return fmt.Errorf("harness: parse: %w", err) - } - for _, e := range st.Entries { - if s.entries[e.Kind] == nil { - s.entries[e.Kind] = map[string]*Entry{} - } - cp := e - s.entries[e.Kind][e.ID] = &cp - } - s.refinements = st.History - return nil -} - -func (s *Store) save() error { - if s.dir == "" { - return nil - } - if err := os.MkdirAll(s.dir, 0o750); err != nil { - return err - } - var all []Entry - for _, m := range s.entries { - for _, e := range m { - all = append(all, *e) - } - } - sort.Slice(all, func(i, j int) bool { - if all[i].Kind != all[j].Kind { - return all[i].Kind < all[j].Kind - } - return all[i].Title < all[j].Title - }) - data, err := json.MarshalIndent(struct { - Schema int `json:"schema"` - Entries []Entry `json:"entries"` - History []Refinement `json:"history"` - }{Schema: 1, Entries: all, History: s.refinements}, "", " ") - if err != nil { - return err - } - tmp := s.path() + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - return err - } - return os.Rename(tmp, s.path()) -} - -// Create adds a new entry and records a refinement event. -func (s *Store) Create(kind Kind, title, content, evidence, source string) (*Entry, error) { - if !kind.Valid() { - return nil, fmt.Errorf("harness: invalid kind %q", kind) - } - s.mu.Lock() - defer s.mu.Unlock() - id := slug(kind, title) - e := &Entry{ - ID: id, Kind: kind, Title: title, Content: content, - Scope: ScopeLocal, Version: 1, Evidence: evidence, Source: source, - CreatedAt: time.Now(), UpdatedAt: time.Now(), - } - s.entries[kind][id] = e - s.recordLocked("create", kind, id, evidence, "") - return s.saveThen(e) -} - -// Refine updates an existing entry (or creates it if absent), bumping the -// version and recording an evidence-backed refinement event. -func (s *Store) Refine(kind Kind, title, content, evidence string) (*Entry, error) { - if !kind.Valid() { - return nil, fmt.Errorf("harness: invalid kind %q", kind) - } - s.mu.Lock() - defer s.mu.Unlock() - id := slug(kind, title) - now := time.Now() - if e, ok := s.entries[kind][id]; ok { - e.Content = content - e.Version++ - e.Evidence = evidence - e.UpdatedAt = now - s.recordLocked("update", kind, id, evidence, "") - return s.saveThen(e) - } - e := &Entry{ - ID: id, Kind: kind, Title: title, Content: content, - Scope: ScopeLocal, Version: 1, Evidence: evidence, CreatedAt: now, UpdatedAt: now, - } - s.entries[kind][id] = e - s.recordLocked("create", kind, id, evidence, "") - return s.saveThen(e) -} - -// Delete removes an entry, recording the event. -func (s *Store) Delete(kind Kind, title, evidence string) error { - if !kind.Valid() { - return fmt.Errorf("harness: invalid kind %q", kind) - } - s.mu.Lock() - defer s.mu.Unlock() - id := slug(kind, title) - if _, ok := s.entries[kind][id]; !ok { - return fmt.Errorf("harness: %s %q not found", kind, title) - } - delete(s.entries[kind], id) - s.recordLocked("delete", kind, id, evidence, "") - return s.save() -} - -// Get returns a copy of an entry. -func (s *Store) Get(kind Kind, title string) (*Entry, bool) { - s.mu.Lock() - defer s.mu.Unlock() - e, ok := s.entries[kind][slug(kind, title)] - if !ok { - return nil, false - } - cp := *e - return &cp, true -} - -// List returns all entries of a kind, sorted by title. -func (s *Store) List(kind Kind) []Entry { - s.mu.Lock() - defer s.mu.Unlock() - var out []Entry - for _, e := range s.entries[kind] { - out = append(out, *e) - } - sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title }) - return out -} - -// History returns the recorded refinement events, oldest first. -func (s *Store) History() []Refinement { - s.mu.Lock() - defer s.mu.Unlock() - return append([]Refinement{}, s.refinements...) -} - -// Snapshot returns a point-in-time copy of all entries (for rollback). -func (s *Store) Snapshot() Snapshot { - s.mu.Lock() - defer s.mu.Unlock() - var all []Entry - for _, m := range s.entries { - for _, e := range m { - all = append(all, *e) - } - } - return Snapshot{Schema: 1, Entries: all} -} - -// Restore replaces all entries with a snapshot, recording a rollback event. -func (s *Store) Restore(snap Snapshot, evidence string) error { - s.mu.Lock() - defer s.mu.Unlock() - for k := range s.entries { - s.entries[k] = map[string]*Entry{} - } - for i := range snap.Entries { - e := snap.Entries[i] - if s.entries[e.Kind] == nil { - s.entries[e.Kind] = map[string]*Entry{} - } - cp := e - s.entries[e.Kind][e.ID] = &cp - } - s.recordLocked("rollback", "", "", evidence, fmt.Sprintf("%d entries", len(snap.Entries))) - return s.save() -} - -func (s *Store) recordLocked(action string, kind Kind, id, evidence, outcome string) { - s.refinements = append(s.refinements, Refinement{ - ID: fmt.Sprintf("r-%d", len(s.refinements)+1), - Trigger: action + " " + string(kind) + " " + id, - Changes: []string{action + ":" + string(kind) + ":" + id}, - Evidence: evidence, - Outcome: outcome, - CreatedAt: time.Now(), - }) -} - -func (s *Store) saveThen(e *Entry) (*Entry, error) { - if err := s.save(); err != nil { - return nil, err - } - cp := *e - return &cp, nil -} - -// slug builds a stable entry id from kind + title. -func slug(kind Kind, title string) string { - t := strings.ToLower(strings.TrimSpace(title)) - t = strings.ReplaceAll(t, " ", "-") - t = strings.ReplaceAll(t, "/", "-") - return string(kind) + ":" + t -} diff --git a/internal/intelligence/harness/harness_test.go b/internal/intelligence/harness/harness_test.go deleted file mode 100644 index d18471dfc..000000000 --- a/internal/intelligence/harness/harness_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package harness - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCreateAndGet(t *testing.T) { - s, _ := New("") - e, err := s.Create(KindMemory, "user-prefers-rust", "prefers Rust over Go", "user stated in session", "session-1") - if err != nil { - t.Fatal(err) - } - if e.Version != 1 { - t.Fatalf("version = %d", e.Version) - } - got, ok := s.Get(KindMemory, "user-prefers-rust") - if !ok || got.Content != "prefers Rust over Go" { - t.Fatalf("get = %+v ok=%v", got, ok) - } -} - -func TestRefineBumpsVersionAndRecordsEvidence(t *testing.T) { - s, _ := New("") - _, _ = s.Create(KindSkill, "deploy", "step one", "initial", "s") - refined, err := s.Refine(KindSkill, "deploy", "step one, step two", "observed new step") - if err != nil { - t.Fatal(err) - } - if refined.Version != 2 { - t.Fatalf("version = %d, want 2", refined.Version) - } - if refined.Evidence != "observed new step" { - t.Fatalf("evidence = %q", refined.Evidence) - } - hist := s.History() - if len(hist) != 2 || hist[1].Evidence != "observed new step" { - t.Fatalf("history = %+v", hist) - } -} - -func TestSnapshotRestoreRollback(t *testing.T) { - s, _ := New("") - _, _ = s.Create(KindMemory, "m1", "old value", "e1", "s") - snap := s.Snapshot() - _, _ = s.Refine(KindMemory, "m1", "new value", "e2") - if got, _ := s.Get(KindMemory, "m1"); got.Content != "new value" { - t.Fatalf("after refine = %q", got.Content) - } - if err := s.Restore(snap, "revert bad change"); err != nil { - t.Fatal(err) - } - got, _ := s.Get(KindMemory, "m1") - if got.Content != "old value" { - t.Fatalf("after rollback = %q", got.Content) - } -} - -func TestDelete(t *testing.T) { - s, _ := New("") - _, _ = s.Create(KindSubagent, "reviewer", "prompt", "e", "s") - if err := s.Delete(KindSubagent, "reviewer", "no longer used"); err != nil { - t.Fatal(err) - } - if _, ok := s.Get(KindSubagent, "reviewer"); ok { - t.Fatal("entry should be deleted") - } -} - -func TestPersistence(t *testing.T) { - dir := t.TempDir() - s1, _ := New(dir) - _, _ = s1.Create(KindPrompt, "style", "be concise", "evidence", "s") - s1.Refine(KindPrompt, "style", "be very concise", "more evidence") - - s2, err := New(dir) - if err != nil { - t.Fatal(err) - } - got, ok := s2.Get(KindPrompt, "style") - if !ok || got.Version != 2 || got.Content != "be very concise" { - t.Fatalf("reloaded = %+v ok=%v", got, ok) - } - if len(s2.History()) != 2 { - t.Fatalf("history not persisted: %d", len(s2.History())) - } -} - -func TestInvalidKind(t *testing.T) { - s, _ := New("") - if _, err := s.Create("bogus", "x", "y", "", ""); err == nil || !strings.Contains(err.Error(), "invalid kind") { - t.Fatalf("err = %v", err) - } -} - -func TestListSorted(t *testing.T) { - s, _ := New("") - _, _ = s.Create(KindMemory, "zeta", "1", "", "") - _, _ = s.Create(KindMemory, "alpha", "2", "", "") - items := s.List(KindMemory) - if len(items) != 2 || items[0].Title != "alpha" || items[1].Title != "zeta" { - t.Fatalf("items = %+v", items) - } -} - -func TestFilePersistenceRoundTrip(t *testing.T) { - dir := t.TempDir() - s, _ := New(dir) - _, _ = s.Create(KindSkill, "s1", "c", "", "") - if _, err := os.Stat(filepath.Join(dir, "harness.json")); err != nil { - t.Fatalf("harness.json not written: %v", err) - } -} diff --git a/internal/localize/files.go b/internal/localize/files.go deleted file mode 100644 index fda789a14..000000000 --- a/internal/localize/files.go +++ /dev/null @@ -1,213 +0,0 @@ -package localize - -import ( - "io/fs" - "path/filepath" - "sort" - "strings" -) - -// defaultIgnoreDirs are directories skipped during the file walk. -var defaultIgnoreDirs = map[string]bool{ - ".git": true, - "node_modules": true, - "vendor": true, - "__pycache__": true, - ".venv": true, - "venv": true, - "dist": true, - "build": true, - ".next": true, - ".nuxt": true, - "target": true, - "bin": true, - "obj": true, - ".idea": true, - ".vscode": true, -} - -// supportedExts lists file extensions we can analyze. -var supportedExts = map[string]bool{ - ".go": true, - ".py": true, - ".ts": true, - ".tsx": true, - ".js": true, - ".jsx": true, - ".rs": true, - ".java": true, -} - -// findFiles walks rootDir and returns files scored against the query keywords. -// It returns at most maxFiles results, sorted by descending score. -func findFiles(rootDir string, query string, maxFiles int) ([]FileMatch, error) { - keywords := extractKeywords(query) - if len(keywords) == 0 { - return nil, nil - } - - var results []FileMatch - - err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err // propagate errors - } - if d.IsDir() { - if defaultIgnoreDirs[filepath.Base(path)] { - return filepath.SkipDir - } - return nil - } - ext := filepath.Ext(path) - if !supportedExts[ext] { - return nil - } - - relPath, relErr := filepath.Rel(rootDir, path) - if relErr != nil { - relPath = path - } - - score, reason := scoreFile(relPath, keywords) - if score > 0 { - results = append(results, FileMatch{ - Path: relPath, - Score: score, - Reason: reason, - }) - } - return nil - }) - if err != nil { - return nil, err - } - - // Sort by score descending, then by path for stability - sort.Slice(results, func(i, j int) bool { - if results[i].Score != results[j].Score { - return results[i].Score > results[j].Score - } - return results[i].Path < results[j].Path - }) - - // Trim to maxFiles - if len(results) > maxFiles { - results = results[:maxFiles] - } - - return results, nil -} - -// scoreFile scores a file path against a set of keywords. -// Returns the aggregate score and a human-readable reason. -func scoreFile(relPath string, keywords []string) (float64, string) { - lower := strings.ToLower(relPath) - base := strings.ToLower(filepath.Base(relPath)) - nameNoExt := strings.TrimSuffix(base, filepath.Ext(base)) - - // Split path into components for matching - parts := strings.FieldsFunc(lower, func(r rune) bool { - return r == '/' || r == '\\' || r == '_' || r == '-' || r == '.' - }) - - var score float64 - var reasons []string - - for _, kw := range keywords { - kwLower := strings.ToLower(kw) - - // Exact filename match (highest signal) - if nameNoExt == kwLower { - score += 10.0 - reasons = append(reasons, "exact filename match: "+kw) - continue - } - - // Filename contains keyword - if strings.Contains(nameNoExt, kwLower) { - score += 5.0 - reasons = append(reasons, "filename contains: "+kw) - continue - } - - // Path component exact match - componentMatch := false - for _, part := range parts { - if part == kwLower { - score += 3.0 - reasons = append(reasons, "path component match: "+kw) - componentMatch = true - break - } - } - if componentMatch { - continue - } - - // Substring of any path component - substringMatch := false - for _, part := range parts { - if strings.Contains(part, kwLower) { - score += 1.5 - reasons = append(reasons, "path substring: "+kw) - substringMatch = true - break - } - } - if substringMatch { - continue - } - - // Full path contains keyword - if strings.Contains(lower, kwLower) { - score += 1.0 - reasons = append(reasons, "path contains: "+kw) - } - } - - reason := strings.Join(reasons, "; ") - return score, reason -} - -// extractKeywords splits a query into meaningful keywords for matching. -// It removes common stop words and short tokens. -func extractKeywords(query string) []string { - stopWords := map[string]bool{ - "a": true, "an": true, "the": true, "is": true, "in": true, - "on": true, "at": true, "to": true, "for": true, "of": true, - "and": true, "or": true, "not": true, "it": true, "this": true, - "that": true, "with": true, "from": true, "by": true, "as": true, - "be": true, "are": true, "was": true, "were": true, "been": true, - "has": true, "have": true, "had": true, "do": true, "does": true, - "did": true, "will": true, "would": true, "could": true, "should": true, - "may": true, "might": true, "can": true, "but": true, "if": true, - "when": true, "where": true, "how": true, "what": true, "which": true, - "who": true, "whom": true, "why": true, - } - - // Split on whitespace and punctuation - tokens := strings.FieldsFunc(query, func(r rune) bool { - return r == ' ' || r == '\t' || r == '\n' || r == ',' || - r == '.' || r == ';' || r == ':' || r == '!' || r == '?' || - r == '(' || r == ')' || r == '[' || r == ']' || r == '{' || r == '}' || - r == '"' || r == '\'' || r == '`' - }) - - var keywords []string - seen := map[string]bool{} - for _, tok := range tokens { - lower := strings.ToLower(tok) - if len(lower) < 2 { - continue - } - if stopWords[lower] { - continue - } - if seen[lower] { - continue - } - seen[lower] = true - keywords = append(keywords, lower) - } - return keywords -} diff --git a/internal/localize/localize.go b/internal/localize/localize.go deleted file mode 100644 index ef1c19274..000000000 --- a/internal/localize/localize.go +++ /dev/null @@ -1,201 +0,0 @@ -// Package localize implements hierarchical fault localization inspired by -// OpenAutoCoder/Agentless. Given a bug description or task, it narrows down -// the location of relevant code in three stages: -// -// 1. File-level -- identify the most relevant files by keyword matching -// against file paths and directory structure. -// 2. Symbol-level -- within those files, extract functions/types/methods -// via regex-based parsing and rank them against the query. -// 3. Edit-level -- extract the code blocks for the top symbols with -// surrounding context so the caller knows exactly where to edit. -// -// The package has zero external dependencies (only stdlib) and works across -// Go, Python, TypeScript, JavaScript, Rust, and Java. -package localize - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strings" -) - -// Localization holds the results of a three-stage localization pass. -type Localization struct { - Files []FileMatch // Stage 1: ranked files - Symbols []SymbolMatch // Stage 2: ranked symbols within those files - Context []CodeBlock // Stage 3: code blocks for top symbols -} - -// FileMatch represents a file identified as relevant in Stage 1. -type FileMatch struct { - Path string // relative to the repo root - Score float64 // higher = more relevant - Reason string // human-readable explanation of the score -} - -// SymbolMatch represents a symbol identified as relevant in Stage 2. -type SymbolMatch struct { - File string // relative path to the file - Name string // symbol name (e.g. "Localize", "Session.Send") - Kind string // "function", "method", "type", "const", "var", "class" - StartLine int // first line of the symbol definition - EndLine int // estimated last line - Score float64 // higher = more relevant -} - -// CodeBlock is a snippet of source code extracted in Stage 3. -type CodeBlock struct { - File string // relative path - StartLine int // first line (1-based, inclusive) - EndLine int // last line (1-based, inclusive) - Content string // the source text (including context lines) -} - -// Localize runs hierarchical fault localization on rootDir for the given query. -// It returns results from all three stages. -func Localize(rootDir string, query string, opts ...Option) (*Localization, error) { - cfg := defaults() - for _, o := range opts { - o(cfg) - } - - // Resolve rootDir to absolute path - absRoot, err := filepath.Abs(rootDir) - if err != nil { - return nil, fmt.Errorf("localize: invalid root dir: %w", err) - } - - // Stage 1: File-level localization - files, err := findFiles(absRoot, query, cfg.maxFiles) - if err != nil { - return nil, fmt.Errorf("localize: file search failed: %w", err) - } - - result := &Localization{ - Files: files, - } - - if len(files) == 0 { - return result, nil - } - - // Stage 2: Symbol-level localization - symbols, err := findSymbols(absRoot, files, query, cfg.maxSymbols, cfg.language) - if err != nil { - return nil, fmt.Errorf("localize: symbol search failed: %w", err) - } - result.Symbols = symbols - - if len(symbols) == 0 { - return result, nil - } - - // Stage 3: Edit-level localization — extract code blocks for top symbols - result.Context = extractCodeBlocks(absRoot, symbols, cfg.contextLines) - - return result, nil -} - -// extractCodeBlocks reads the relevant lines for each symbol match, plus -// contextLines of surrounding context. -func extractCodeBlocks(rootDir string, symbols []SymbolMatch, contextLines int) []CodeBlock { - // Cache file contents to avoid re-reading the same file. - fileLines := map[string][]string{} - - var blocks []CodeBlock - for _, sym := range symbols { - absPath := filepath.Join(rootDir, sym.File) - - lines, ok := fileLines[sym.File] - if !ok { - var err error - lines, err = readFileLines(absPath) - if err != nil { - continue - } - fileLines[sym.File] = lines - } - - totalLines := len(lines) - start := sym.StartLine - contextLines - if start < 1 { - start = 1 - } - end := sym.EndLine + contextLines - if end > totalLines { - end = totalLines - } - - // Build content with line numbers - var b strings.Builder - for i := start; i <= end; i++ { - _, _ = fmt.Fprintf(&b, "%4d | %s\n", i, lines[i-1]) - } - - blocks = append(blocks, CodeBlock{ - File: sym.File, - StartLine: start, - EndLine: end, - Content: b.String(), - }) - } - - return blocks -} - -// readFileLines reads a file and returns its lines. -func readFileLines(path string) ([]string, error) { - f, err := os.Open(path) // #nosec G304 -- path is a source file within the project being localized, supplied by internal callers, not external input - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - var lines []string - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - return lines, scanner.Err() -} - -// FormatSummary returns a human-readable summary of the localization result. -func (loc *Localization) FormatSummary() string { - if loc == nil { - return "no localization results" - } - - var b strings.Builder - - b.WriteString("=== File-level localization ===\n") - if len(loc.Files) == 0 { - b.WriteString(" (no files matched)\n") - } - for i, f := range loc.Files { - _, _ = fmt.Fprintf(&b, " %d. %s (score: %.1f) — %s\n", i+1, f.Path, f.Score, f.Reason) - } - - b.WriteString("\n=== Symbol-level localization ===\n") - if len(loc.Symbols) == 0 { - b.WriteString(" (no symbols matched)\n") - } - for i, s := range loc.Symbols { - fmt.Fprintf(&b, " %d. %s:%s %s (lines %d-%d, score: %.1f)\n", - i+1, s.File, s.Kind, s.Name, s.StartLine, s.EndLine, s.Score) - } - - b.WriteString("\n=== Edit-level localization ===\n") - if len(loc.Context) == 0 { - b.WriteString(" (no code blocks)\n") - } - for _, cb := range loc.Context { - _, _ = fmt.Fprintf(&b, "--- %s (lines %d-%d) ---\n", cb.File, cb.StartLine, cb.EndLine) - b.WriteString(cb.Content) - b.WriteString("\n") - } - - return b.String() -} diff --git a/internal/localize/localize_test.go b/internal/localize/localize_test.go deleted file mode 100644 index 2d0465abf..000000000 --- a/internal/localize/localize_test.go +++ /dev/null @@ -1,429 +0,0 @@ -package localize - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// testRoot returns the hawk repo root (grandparent of internal/localize/). -func testRoot(t *testing.T) string { - t.Helper() - // We are in hawk/internal/localize, so root is two directories up. - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - root := filepath.Dir(filepath.Dir(wd)) - // Sanity check: go.mod should exist at root. - if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { - t.Fatalf("could not find go.mod at %s: %v", root, err) - } - return root -} - -// --------------------------------------------------------------------------- -// Stage 1: File-level localization -// --------------------------------------------------------------------------- - -func TestExtractKeywords(t *testing.T) { - kw := extractKeywords("the session is broken and needs a fix") - if len(kw) == 0 { - t.Fatal("expected keywords, got none") - } - // "session" should be present; stop words like "the", "is", "a" should not - found := false - for _, k := range kw { - if k == "session" { - found = true - } - if k == "the" || k == "is" || k == "a" { - t.Errorf("stop word %q should have been filtered", k) - } - } - if !found { - t.Errorf("expected keyword 'session' in %v", kw) - } -} - -func TestFindFiles_Session(t *testing.T) { - root := testRoot(t) - files, err := findFiles(root, "session management", 10) - if err != nil { - t.Fatal(err) - } - if len(files) == 0 { - t.Fatal("expected at least one file match for 'session'") - } - - // engine/session.go should be among the top results - found := false - for _, f := range files { - if strings.Contains(f.Path, "session") { - found = true - break - } - } - if !found { - t.Errorf("expected a session-related file in results: %+v", files) - } -} - -func TestFindFiles_Repomap(t *testing.T) { - root := testRoot(t) - files, err := findFiles(root, "repomap generation", 10) - if err != nil { - t.Fatal(err) - } - if len(files) == 0 { - t.Fatal("expected at least one file match for 'repomap'") - } - - // repomap/repomap.go should appear - found := false - for _, f := range files { - if strings.Contains(f.Path, filepath.Join("repomap", "repomap.go")) { - found = true - break - } - } - if !found { - t.Errorf("expected repomap/repomap.go in results: %+v", files) - } -} - -func TestScoreFile(t *testing.T) { - tests := []struct { - path string - keywords []string - wantPos bool // expect positive score - }{ - {"internal/engine/session.go", []string{"session"}, true}, - {"repomap/parser.go", []string{"parser"}, true}, - {"repomap/parser.go", []string{"unrelated", "nothing"}, false}, - {"cmd/root.go", []string{"root", "cmd"}, true}, - } - for _, tt := range tests { - score, _ := scoreFile(tt.path, tt.keywords) - if tt.wantPos && score <= 0 { - t.Errorf("scoreFile(%q, %v) = %f, want positive", tt.path, tt.keywords, score) - } - if !tt.wantPos && score > 0 { - t.Errorf("scoreFile(%q, %v) = %f, want zero", tt.path, tt.keywords, score) - } - } -} - -// --------------------------------------------------------------------------- -// Stage 2: Symbol-level localization -// --------------------------------------------------------------------------- - -func TestExtractSymbols_Go(t *testing.T) { - root := testRoot(t) - // Use engine/session.go as a known Go file. - path := filepath.Join(root, "internal", "engine", "session.go") - symbols, err := extractSymbols(path, "") - if err != nil { - t.Fatal(err) - } - if len(symbols) == 0 { - t.Fatal("expected symbols from engine/session.go") - } - - // Should find Session type and NewSession function - foundSession := false - foundNewSession := false - for _, s := range symbols { - if s.name == "Session" && s.kind == "type" { - foundSession = true - } - if s.name == "NewSession" && s.kind == "function" { - foundNewSession = true - } - } - if !foundSession { - t.Error("expected to find type Session") - } - if !foundNewSession { - t.Error("expected to find func NewSession") - } -} - -func TestExtractSymbols_GoRepomap(t *testing.T) { - root := testRoot(t) - path := filepath.Join(root, "internal", "intelligence", "repomap", "repomap.go") - symbols, err := extractSymbols(path, "") - if err != nil { - t.Fatal(err) - } - - // Should find Generate function - found := false - for _, s := range symbols { - if s.name == "Generate" && s.kind == "function" { - found = true - break - } - } - if !found { - names := make([]string, len(symbols)) - for i, s := range symbols { - names[i] = s.kind + " " + s.name - } - t.Errorf("expected to find func Generate in symbols: %v", names) - } -} - -func TestScoreSymbol(t *testing.T) { - tests := []struct { - name string - kind string - keywords []string - wantPos bool - }{ - {"NewSession", "function", []string{"session"}, true}, - {"NewSession", "function", []string{"new", "session"}, true}, - {"Generate", "function", []string{"generate"}, true}, - {"Generate", "function", []string{"unrelated"}, false}, - } - for _, tt := range tests { - score := scoreSymbol(tt.name, tt.kind, tt.keywords) - if tt.wantPos && score <= 0 { - t.Errorf("scoreSymbol(%q, %q, %v) = %f, want positive", tt.name, tt.kind, tt.keywords, score) - } - if !tt.wantPos && score > 0 { - t.Errorf("scoreSymbol(%q, %q, %v) = %f, want zero", tt.name, tt.kind, tt.keywords, score) - } - } -} - -func TestSplitIdentifier(t *testing.T) { - tests := []struct { - input string - want []string - }{ - {"NewSession", []string{"new", "session"}}, - {"find_files", []string{"find", "files"}}, - {"extractCodeBlocks", []string{"extract", "code", "blocks"}}, - {"Session.Send", []string{"session", "send"}}, - } - for _, tt := range tests { - got := splitIdentifier(tt.input) - if len(got) != len(tt.want) { - t.Errorf("splitIdentifier(%q) = %v, want %v", tt.input, got, tt.want) - continue - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("splitIdentifier(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) - } - } - } -} - -func TestFindSymbols_Integration(t *testing.T) { - root := testRoot(t) - files := []FileMatch{ - {Path: filepath.Join("internal", "engine", "session.go"), Score: 10.0}, - } - symbols, err := findSymbols(root, files, "session model provider", 20, "") - if err != nil { - t.Fatal(err) - } - if len(symbols) == 0 { - t.Fatal("expected symbol matches") - } - - // Session, SetModel, SetProvider should appear - names := map[string]bool{} - for _, s := range symbols { - names[s.Name] = true - } - for _, want := range []string{"Session"} { - if !names[want] { - t.Errorf("expected symbol %q in results, got: %v", want, symbols) - } - } -} - -// --------------------------------------------------------------------------- -// Stage 3: Full pipeline -// --------------------------------------------------------------------------- - -func TestLocalize_EndToEnd(t *testing.T) { - root := testRoot(t) - loc, err := Localize(root, "session model routing", WithMaxFiles(5), WithMaxSymbols(10)) - if err != nil { - t.Fatal(err) - } - - // Stage 1: should have files - if len(loc.Files) == 0 { - t.Error("Stage 1: expected file matches") - } - - // Stage 2: should have symbols - if len(loc.Symbols) == 0 { - t.Error("Stage 2: expected symbol matches") - } - - // Stage 3: should have code blocks - if len(loc.Context) == 0 { - t.Error("Stage 3: expected code blocks") - } - - // Code blocks should contain actual code - for _, cb := range loc.Context { - if cb.Content == "" { - t.Errorf("empty code block for %s:%d-%d", cb.File, cb.StartLine, cb.EndLine) - } - } -} - -func TestLocalize_EmptyQuery(t *testing.T) { - root := testRoot(t) - loc, err := Localize(root, "") - if err != nil { - t.Fatal(err) - } - if len(loc.Files) != 0 { - t.Error("expected no file matches for empty query") - } -} - -func TestLocalize_NoMatch(t *testing.T) { - root := testRoot(t) - loc, err := Localize(root, "xyzzyplughtwisty") - if err != nil { - t.Fatal(err) - } - if len(loc.Files) != 0 { - t.Errorf("expected no file matches for gibberish, got %d", len(loc.Files)) - } -} - -func TestLocalize_WithOptions(t *testing.T) { - root := testRoot(t) - loc, err := Localize( - root, "repomap generate symbols", - WithMaxFiles(3), - WithMaxSymbols(5), - WithContextLines(2), - ) - if err != nil { - t.Fatal(err) - } - - if len(loc.Files) > 3 { - t.Errorf("expected at most 3 files, got %d", len(loc.Files)) - } - if len(loc.Symbols) > 5 { - t.Errorf("expected at most 5 symbols, got %d", len(loc.Symbols)) - } -} - -func TestFormatSummary(t *testing.T) { - root := testRoot(t) - loc, err := Localize(root, "session", WithMaxFiles(3), WithMaxSymbols(3)) - if err != nil { - t.Fatal(err) - } - - summary := loc.FormatSummary() - if summary == "" { - t.Fatal("expected non-empty summary") - } - if !strings.Contains(summary, "File-level") { - t.Error("summary should contain 'File-level' header") - } - if !strings.Contains(summary, "Symbol-level") { - t.Error("summary should contain 'Symbol-level' header") - } - if !strings.Contains(summary, "Edit-level") { - t.Error("summary should contain 'Edit-level' header") - } -} - -func TestLocalize_LanguageFilter(t *testing.T) { - root := testRoot(t) - loc, err := Localize( - root, "session model", - WithLanguage("go"), - WithMaxFiles(5), - WithMaxSymbols(10), - ) - if err != nil { - t.Fatal(err) - } - // With language=go, all symbols should come from .go files - for _, s := range loc.Symbols { - if !strings.HasSuffix(s.File, ".go") { - t.Errorf("expected .go file, got %s", s.File) - } - } -} - -// --------------------------------------------------------------------------- -// Edge cases and helpers -// --------------------------------------------------------------------------- - -func TestLangFromExt(t *testing.T) { - tests := map[string]string{ - ".go": "go", - ".py": "python", - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".rs": "rust", - ".java": "java", - ".txt": "", - ".md": "", - } - for ext, want := range tests { - got := langFromExt(ext) - if got != want { - t.Errorf("langFromExt(%q) = %q, want %q", ext, got, want) - } - } -} - -func TestReadFileLines(t *testing.T) { - root := testRoot(t) - lines, err := readFileLines(filepath.Join(root, "go.mod")) - if err != nil { - t.Fatal(err) - } - if len(lines) < 3 { - t.Errorf("expected go.mod to have at least 3 lines, got %d", len(lines)) - } - if !strings.HasPrefix(lines[0], "module") { - t.Errorf("expected first line to start with 'module', got %q", lines[0]) - } -} - -func TestSplitCamelCase(t *testing.T) { - tests := []struct { - input string - want []string - }{ - {"NewSession", []string{"new", "session"}}, - {"session", []string{"session"}}, - {"HTTPClient", []string{"h", "t", "t", "p", "client"}}, - {"parseGo", []string{"parse", "go"}}, - } - for _, tt := range tests { - got := splitCamelCase(tt.input) - if len(got) != len(tt.want) { - t.Errorf("splitCamelCase(%q) = %v (len %d), want %v (len %d)", - tt.input, got, len(got), tt.want, len(tt.want)) - continue - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("splitCamelCase(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) - } - } - } -} diff --git a/internal/localize/options.go b/internal/localize/options.go deleted file mode 100644 index ce85255b2..000000000 --- a/internal/localize/options.go +++ /dev/null @@ -1,57 +0,0 @@ -package localize - -// Option configures a Localize call using the functional-options pattern. -type Option func(*config) - -type config struct { - maxFiles int - maxSymbols int - language string // empty = auto-detect from extension - contextLines int // lines of context around a symbol for Stage 3 -} - -func defaults() *config { - return &config{ - maxFiles: 10, - maxSymbols: 20, - language: "", - contextLines: 3, - } -} - -// WithMaxFiles sets the maximum number of files returned from Stage 1. -func WithMaxFiles(n int) Option { - return func(c *config) { - if n > 0 { - c.maxFiles = n - } - } -} - -// WithMaxSymbols sets the maximum number of symbols returned from Stage 2. -func WithMaxSymbols(n int) Option { - return func(c *config) { - if n > 0 { - c.maxSymbols = n - } - } -} - -// WithLanguage restricts symbol extraction to a single language. -// Valid values: "go", "python", "typescript", "javascript", "rust", "java". -// An empty string means auto-detect from extension (the default). -func WithLanguage(lang string) Option { - return func(c *config) { - c.language = lang - } -} - -// WithContextLines sets the number of surrounding lines included in -// Stage 3 CodeBlocks. -func WithContextLines(n int) Option { - return func(c *config) { - if n >= 0 { - c.contextLines = n - } - } -} diff --git a/internal/localize/symbols.go b/internal/localize/symbols.go deleted file mode 100644 index 19667f5a1..000000000 --- a/internal/localize/symbols.go +++ /dev/null @@ -1,330 +0,0 @@ -package localize - -import ( - "bufio" - "os" - "path/filepath" - "regexp" - "sort" - "strings" -) - -// ── Language detection ── - -// langFromExt returns a language identifier for the given file extension. -func langFromExt(ext string) string { - switch ext { - case ".go": - return "go" - case ".py": - return "python" - case ".ts", ".tsx": - return "typescript" - case ".js", ".jsx": - return "javascript" - case ".rs": - return "rust" - case ".java": - return "java" - } - return "" -} - -// ── Regex patterns per language ── - -// Each pattern set captures: the symbol name (group 1) and we derive the kind -// from which regex matched. - -type symbolPattern struct { - re *regexp.Regexp - kind string -} - -var goPatterns = []symbolPattern{ - {regexp.MustCompile(`^func\s+\(\s*\w+\s+\*?(\w+)\)\s+(\w+)\s*\(`), "method"}, - {regexp.MustCompile(`^func\s+(\w+)\s*\(`), "function"}, - {regexp.MustCompile(`^type\s+(\w+)\s+struct\b`), "type"}, - {regexp.MustCompile(`^type\s+(\w+)\s+interface\b`), "type"}, - {regexp.MustCompile(`^type\s+(\w+)\s+`), "type"}, - {regexp.MustCompile(`^var\s+(\w+)\s+`), "var"}, - {regexp.MustCompile(`^const\s+(\w+)\s+`), "const"}, -} - -var pythonPatterns = []symbolPattern{ - {regexp.MustCompile(`^class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^async\s+def\s+(\w+)`), "function"}, - {regexp.MustCompile(`^\s{4}async\s+def\s+(\w+)`), "method"}, - {regexp.MustCompile(`^def\s+(\w+)`), "function"}, - {regexp.MustCompile(`^\s{4}def\s+(\w+)`), "method"}, -} - -var tsPatterns = []symbolPattern{ - {regexp.MustCompile(`^(?:export\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:export\s+)?interface\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:export\s+)?type\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:export\s+)?(?:async\s+)?function\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\w+\s*=>`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)`), "const"}, - // Method inside a class body - {regexp.MustCompile(`^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{`), "method"}, -} - -var jsPatterns = []symbolPattern{ - {regexp.MustCompile(`^(?:export\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:export\s+)?(?:async\s+)?function\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\w+\s*=>`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)`), "const"}, - {regexp.MustCompile(`^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{`), "method"}, -} - -var rustPatterns = []symbolPattern{ - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?fn\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)`), "type"}, - {regexp.MustCompile(`^impl(?:<[^>]*>)?\s+(\w+)`), "type"}, - {regexp.MustCompile(`^\s+(?:pub(?:\([^)]*\))?\s+)?fn\s+(\w+)`), "method"}, -} - -var javaPatterns = []symbolPattern{ - {regexp.MustCompile(`^(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:public|private|protected)?\s*(?:static\s+)?interface\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:public|private|protected)?\s*(?:static\s+)?enum\s+(\w+)`), "type"}, - {regexp.MustCompile(`^\s+(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:\w+(?:<[^>]*>)?)\s+(\w+)\s*\(`), "method"}, -} - -// patternsForLang returns the symbol patterns for a given language. -func patternsForLang(lang string) []symbolPattern { - switch lang { - case "go": - return goPatterns - case "python": - return pythonPatterns - case "typescript": - return tsPatterns - case "javascript": - return jsPatterns - case "rust": - return rustPatterns - case "java": - return javaPatterns - } - return nil -} - -// ── Symbol extraction ── - -// rawSymbol is an intermediate representation before scoring. -type rawSymbol struct { - name string - kind string - startLine int - endLine int // estimated end line -} - -// extractSymbols reads a file and extracts symbols using regex patterns. -// If forceLang is non-empty, it overrides language detection. -func extractSymbols(filePath string, forceLang string) ([]rawSymbol, error) { - ext := filepath.Ext(filePath) - lang := forceLang - if lang == "" { - lang = langFromExt(ext) - } - if lang == "" { - return nil, nil - } - - patterns := patternsForLang(lang) - if len(patterns) == 0 { - return nil, nil - } - - f, err := os.Open(filePath) // #nosec G304 -- filePath is a source file within the project being localized, supplied by internal callers, not external input - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - var lines []string - scanner := bufio.NewScanner(f) - // Increase buffer for files with long lines - scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return nil, err - } - - var symbols []rawSymbol - for i, line := range lines { - lineNum := i + 1 - for _, pat := range patterns { - m := pat.re.FindStringSubmatch(line) - if m == nil { - continue - } - - name := m[1] - // For Go methods, the receiver type is group 1, method name is group 2 - if pat.kind == "method" && lang == "go" && len(m) >= 3 { - name = m[1] + "." + m[2] - } - - symbols = append(symbols, rawSymbol{ - name: name, - kind: pat.kind, - startLine: lineNum, - }) - break // first pattern match wins for this line - } - } - - // Estimate end lines: each symbol ends one line before the next symbol starts, - // or at end-of-file for the last symbol. - for i := range symbols { - if i+1 < len(symbols) { - symbols[i].endLine = symbols[i+1].startLine - 1 - } else { - symbols[i].endLine = len(lines) - } - } - - return symbols, nil -} - -// findSymbols locates symbols in the given files that match the query keywords. -// It returns at most maxSymbols results sorted by score descending. -func findSymbols(rootDir string, files []FileMatch, query string, maxSymbols int, forceLang string) ([]SymbolMatch, error) { - keywords := extractKeywords(query) - if len(keywords) == 0 { - return nil, nil - } - - var results []SymbolMatch - - for _, fm := range files { - absPath := filepath.Join(rootDir, fm.Path) - symbols, err := extractSymbols(absPath, forceLang) - if err != nil { - continue // skip files we cannot read - } - - for _, sym := range symbols { - score := scoreSymbol(sym.name, sym.kind, keywords) - if score <= 0 { - continue - } - // Boost symbols in higher-scoring files - score += fm.Score * 0.1 - - results = append(results, SymbolMatch{ - File: fm.Path, - Name: sym.name, - Kind: sym.kind, - StartLine: sym.startLine, - EndLine: sym.endLine, - Score: score, - }) - } - } - - sort.Slice(results, func(i, j int) bool { - if results[i].Score != results[j].Score { - return results[i].Score > results[j].Score - } - if results[i].File != results[j].File { - return results[i].File < results[j].File - } - return results[i].StartLine < results[j].StartLine - }) - - if len(results) > maxSymbols { - results = results[:maxSymbols] - } - - return results, nil -} - -// scoreSymbol scores a symbol name against the query keywords. -func scoreSymbol(name string, kind string, keywords []string) float64 { - nameLower := strings.ToLower(name) - // Split camelCase and snake_case into parts - nameParts := splitIdentifier(nameLower) - - var score float64 - for _, kw := range keywords { - kwLower := strings.ToLower(kw) - - // Exact name match - if nameLower == kwLower { - score += 10.0 - continue - } - - // Name contains keyword - if strings.Contains(nameLower, kwLower) { - score += 5.0 - continue - } - - // Keyword matches a name part (from camelCase/snake_case split) - partMatch := false - for _, part := range nameParts { - if part == kwLower { - score += 4.0 - partMatch = true - break - } - } - if partMatch { - continue - } - - // Partial substring match in parts - for _, part := range nameParts { - if strings.Contains(part, kwLower) || strings.Contains(kwLower, part) { - score += 2.0 - break - } - } - } - - return score -} - -// splitIdentifier splits a camelCase or snake_case identifier into lowercase parts. -func splitIdentifier(name string) []string { - // First split on underscores, dots and hyphens - parts := strings.FieldsFunc(name, func(r rune) bool { - return r == '_' || r == '.' || r == '-' - }) - - var result []string - for _, part := range parts { - // Split camelCase - result = append(result, splitCamelCase(part)...) - } - return result -} - -// splitCamelCase splits a camelCase string into lowercase tokens. -func splitCamelCase(s string) []string { - if s == "" { - return nil - } - - var parts []string - start := 0 - runes := []rune(s) - for i := 1; i < len(runes); i++ { - if runes[i] >= 'A' && runes[i] <= 'Z' { - parts = append(parts, strings.ToLower(string(runes[start:i]))) - start = i - } - } - parts = append(parts, strings.ToLower(string(runes[start:]))) - return parts -} diff --git a/internal/localize/symbols_test.go b/internal/localize/symbols_test.go deleted file mode 100644 index 41026ba2f..000000000 --- a/internal/localize/symbols_test.go +++ /dev/null @@ -1,513 +0,0 @@ -package localize - -import ( - "os" - "path/filepath" - "testing" -) - -// --------------------------------------------------------------------------- -// langFromExt - comprehensive -// --------------------------------------------------------------------------- - -func TestLangFromExt_AllSupported(t *testing.T) { - tests := []struct { - ext string - want string - }{ - {".go", "go"}, - {".py", "python"}, - {".ts", "typescript"}, - {".tsx", "typescript"}, - {".js", "javascript"}, - {".jsx", "javascript"}, - {".rs", "rust"}, - {".java", "java"}, - } - for _, tt := range tests { - t.Run(tt.ext, func(t *testing.T) { - if got := langFromExt(tt.ext); got != tt.want { - t.Errorf("langFromExt(%q) = %q, want %q", tt.ext, got, tt.want) - } - }) - } -} - -func TestLangFromExt_Unsupported(t *testing.T) { - for _, ext := range []string{".txt", ".md", ".yaml", ".json", ".html", ".css", ".rb"} { - t.Run(ext, func(t *testing.T) { - if got := langFromExt(ext); got != "" { - t.Errorf("langFromExt(%q) = %q, want empty", ext, got) - } - }) - } -} - -// --------------------------------------------------------------------------- -// patternsForLang -// --------------------------------------------------------------------------- - -func TestPatternsForLang_AllLanguages(t *testing.T) { - for _, lang := range []string{"go", "python", "typescript", "javascript", "rust", "java"} { - t.Run(lang, func(t *testing.T) { - pats := patternsForLang(lang) - if len(pats) == 0 { - t.Errorf("patternsForLang(%q) returned empty", lang) - } - }) - } -} - -func TestPatternsForLang_Unknown(t *testing.T) { - pats := patternsForLang("unknown") - if pats != nil { - t.Errorf("expected nil for unknown language, got %d patterns", len(pats)) - } -} - -// --------------------------------------------------------------------------- -// extractSymbols - Go -// --------------------------------------------------------------------------- - -func TestExtractSymbols_GoFunction(t *testing.T) { - dir := t.TempDir() - src := `package main - -func hello() string { - return "hello" -} - -func add(a, b int) int { - return a + b -} -` - path := filepath.Join(dir, "main.go") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "go") - if err != nil { - t.Fatal(err) - } - names := symbolNames(symbols) - if !contains(names, "hello") { - t.Errorf("expected 'hello' in symbols, got %v", names) - } - if !contains(names, "add") { - t.Errorf("expected 'add' in symbols, got %v", names) - } -} - -func TestExtractSymbols_GoMethod(t *testing.T) { - dir := t.TempDir() - src := `package main - -type Server struct{} - -func (s *Server) Start() error { - return nil -} - -func (s *Server) Stop() {} -` - path := filepath.Join(dir, "server.go") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "go") - if err != nil { - t.Fatal(err) - } - - // Go methods should be qualified as Receiver.Method - names := symbolNames(symbols) - if !contains(names, "Server.Start") { - t.Errorf("expected 'Server.Start', got %v", names) - } - if !contains(names, "Server.Stop") { - t.Errorf("expected 'Server.Stop', got %v", names) - } -} - -func TestExtractSymbols_GoType(t *testing.T) { - dir := t.TempDir() - src := `package main - -type Config struct { - Name string -} - -type Handler interface { - Handle() -} -` - path := filepath.Join(dir, "types.go") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "go") - if err != nil { - t.Fatal(err) - } - - names := symbolNames(symbols) - if !contains(names, "Config") { - t.Errorf("expected 'Config', got %v", names) - } - if !contains(names, "Handler") { - t.Errorf("expected 'Handler', got %v", names) - } -} - -func TestExtractSymbols_GoConstVar(t *testing.T) { - dir := t.TempDir() - src := `package main - -const MaxRetries = 3 - -var DefaultTimeout = 30 -` - path := filepath.Join(dir, "constants.go") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "go") - if err != nil { - t.Fatal(err) - } - - kinds := symbolKinds(symbols) - names := symbolNames(symbols) - if !contains(names, "MaxRetries") { - t.Errorf("expected 'MaxRetries', got %v", names) - } - if !contains(names, "DefaultTimeout") { - t.Errorf("expected 'DefaultTimeout', got %v", names) - } - // Verify kinds - if !contains(kinds, "const") { - t.Errorf("expected 'const' kind, got %v", kinds) - } - if !contains(kinds, "var") { - t.Errorf("expected 'var' kind, got %v", kinds) - } -} - -// --------------------------------------------------------------------------- -// extractSymbols - Python -// --------------------------------------------------------------------------- - -func TestExtractSymbols_PythonClass(t *testing.T) { - dir := t.TempDir() - src := `class MyService: - def __init__(self): - pass - -async def fetch_data(url): - pass -` - path := filepath.Join(dir, "service.py") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "python") - if err != nil { - t.Fatal(err) - } - - names := symbolNames(symbols) - if !contains(names, "MyService") { - t.Errorf("expected 'MyService', got %v", names) - } - if !contains(names, "fetch_data") { - t.Errorf("expected 'fetch_data', got %v", names) - } -} - -func TestExtractSymbols_PythonMethod(t *testing.T) { - dir := t.TempDir() - src := `class Parser: - def parse(self, text): - return text - - async def async_parse(self, text): - return text -` - path := filepath.Join(dir, "parser.py") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "python") - if err != nil { - t.Fatal(err) - } - - names := symbolNames(symbols) - if !contains(names, "parse") { - t.Errorf("expected 'parse' method, got %v", names) - } - if !contains(names, "async_parse") { - t.Errorf("expected 'async_parse' method, got %v", names) - } -} - -// --------------------------------------------------------------------------- -// extractSymbols - TypeScript -// --------------------------------------------------------------------------- - -func TestExtractSymbols_TypeScript(t *testing.T) { - dir := t.TempDir() - src := `export interface UserProps { - name: string; -} - -export type UserID = string; - -export class UserService { - async getUser(id: string): Promise { - return { name: "test" }; - } -} - -export function createUser(name: string): UserProps { - return { name }; -} - -export const VERSION = "1.0.0"; -` - path := filepath.Join(dir, "user.ts") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "typescript") - if err != nil { - t.Fatal(err) - } - - names := symbolNames(symbols) - for _, want := range []string{"UserProps", "UserID", "UserService", "createUser", "VERSION"} { - if !contains(names, want) { - t.Errorf("expected %q in symbols, got %v", want, names) - } - } -} - -// --------------------------------------------------------------------------- -// extractSymbols - Rust -// --------------------------------------------------------------------------- - -func TestExtractSymbols_Rust(t *testing.T) { - dir := t.TempDir() - src := `pub struct Config { - name: String, -} - -pub enum Status { - Active, - Inactive, -} - -pub trait Handler { - fn handle(&self); -} - -impl Handler for Config { - fn handle(&self) { - println!("{}", self.name); - } -} - -pub fn new_config(name: &str) -> Config { - Config { name: name.to_string() } -} -` - path := filepath.Join(dir, "config.rs") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "rust") - if err != nil { - t.Fatal(err) - } - - names := symbolNames(symbols) - for _, want := range []string{"Config", "Status", "Handler", "new_config"} { - if !contains(names, want) { - t.Errorf("expected %q in symbols, got %v", want, names) - } - } -} - -// --------------------------------------------------------------------------- -// extractSymbols - unsupext -// --------------------------------------------------------------------------- - -func TestExtractSymbols_UnsupportedExtension(t *testing.T) { - dir := t.TempDir() - src := `some content` - path := filepath.Join(dir, "data.txt") - os.WriteFile(path, []byte(src), 0o644) - - symbols, err := extractSymbols(path, "") - if err != nil { - t.Fatal(err) - } - if symbols != nil { - t.Errorf("expected nil symbols for unsupported ext, got %d", len(symbols)) - } -} - -// --------------------------------------------------------------------------- -// scoreSymbol - comprehensive -// --------------------------------------------------------------------------- - -func TestScoreSymbol_ExactMatch(t *testing.T) { - score := scoreSymbol("Session", "type", []string{"Session"}) - if score < 10 { - t.Errorf("expected score >= 10 for exact match, got %f", score) - } -} - -func TestScoreSymbol_ContainsKeyword(t *testing.T) { - score := scoreSymbol("NewSession", "function", []string{"session"}) - if score < 5 { - t.Errorf("expected score >= 5 for contains match, got %f", score) - } -} - -func TestScoreSymbol_PartMatch(t *testing.T) { - score := scoreSymbol("HandleRequest", "function", []string{"request"}) - if score < 4 { - t.Errorf("expected score >= 4 for part match, got %f", score) - } -} - -func TestScoreSymbol_NoMatch(t *testing.T) { - score := scoreSymbol("ProcessData", "function", []string{"auth", "login"}) - if score != 0 { - t.Errorf("expected score 0 for no match, got %f", score) - } -} - -func TestScoreSymbol_MultipleKeywords(t *testing.T) { - score := scoreSymbol("HandleAuth", "function", []string{"handle", "auth"}) - if score < 8 { - t.Errorf("expected score >= 8 for multi-keyword match, got %f", score) - } -} - -func TestScoreSymbol_CaseInsensitive(t *testing.T) { - score := scoreSymbol("myFunction", "function", []string{"MYFUNCTION"}) - if score < 10 { - t.Errorf("expected score >= 10 for case-insensitive exact match, got %f", score) - } -} - -// --------------------------------------------------------------------------- -// splitCamelCase -// --------------------------------------------------------------------------- - -func TestSplitCamelCase_Empty(t *testing.T) { - got := splitCamelCase("") - if got != nil { - t.Errorf("expected nil for empty, got %v", got) - } -} - -func TestSplitCamelCase_SingleWord(t *testing.T) { - got := splitCamelCase("hello") - if len(got) != 1 || got[0] != "hello" { - t.Errorf("expected ['hello'], got %v", got) - } -} - -func TestSplitCamelCase_MultipleWords(t *testing.T) { - got := splitCamelCase("handleRequest") - if len(got) != 2 || got[0] != "handle" || got[1] != "request" { - t.Errorf("expected ['handle', 'request'], got %v", got) - } -} - -func TestSplitCamelCase_ConsecutiveCaps(t *testing.T) { - got := splitCamelCase("parseJSON") - // Should split at the uppercase letter boundary - if len(got) < 2 { - t.Errorf("expected at least 2 parts, got %v", got) - } -} - -// --------------------------------------------------------------------------- -// extractKeywords -// --------------------------------------------------------------------------- - -func TestExtractKeywords_RemovesStopWords(t *testing.T) { - kw := extractKeywords("how to fix the broken session") - for _, stop := range []string{"how", "to", "the"} { - for _, k := range kw { - if k == stop { - t.Errorf("stop word %q should be filtered", stop) - } - } - } -} - -func TestExtractKeywords_RemovesShortTokens(t *testing.T) { - kw := extractKeywords("a bb ccc dddd") - for _, k := range kw { - if len(k) < 2 { - t.Errorf("token %q is too short (< 2 chars)", k) - } - } -} - -func TestExtractKeywords_Deduplication(t *testing.T) { - kw := extractKeywords("session session session") - seen := map[string]bool{} - for _, k := range kw { - if seen[k] { - t.Errorf("duplicate keyword %q", k) - } - seen[k] = true - } -} - -func TestExtractKeywords_EmptyQuery(t *testing.T) { - kw := extractKeywords("") - if len(kw) != 0 { - t.Errorf("expected 0 keywords for empty query, got %d", len(kw)) - } -} - -func TestExtractKeywords_PunctuationSplit(t *testing.T) { - kw := extractKeywords("fix(session, handler)") - found := false - for _, k := range kw { - if k == "session" { - found = true - } - } - if !found { - t.Errorf("expected 'session' after punctuation split, got %v", kw) - } -} - -// --------------------------------------------------------------------------- -// helpers -// --------------------------------------------------------------------------- - -func symbolNames(syms []rawSymbol) []string { - names := make([]string, len(syms)) - for i, s := range syms { - names[i] = s.name - } - return names -} - -func symbolKinds(syms []rawSymbol) []string { - kinds := make([]string, len(syms)) - for i, s := range syms { - kinds[i] = s.kind - } - return kinds -} - -func contains(ss []string, target string) bool { - for _, s := range ss { - if s == target { - return true - } - } - return false -} diff --git a/internal/lockutil/lockutil.go b/internal/lockutil/lockutil.go deleted file mode 100644 index a3eb6356b..000000000 --- a/internal/lockutil/lockutil.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package lockutil provides the platform-specific file primitives behind the -// O_EXCL lock files used by hawk (daemon, hooks, cron, install): a no-overwrite -// restore for locks that were sidelined during a stale-reclaim attempt, and a -// lock file remover with one cross-platform contract (missing files are a no-op; -// Windows retries transient sharing violations). -// -// Adopted from Zero (internal/lockutil) — atomic stale-lock reclaim primitives. -package lockutil - -import ( - "io" - "os" -) - -// restoreByCopy restores reclaimed to path without overwriting an existing -// path, as a fallback for when the platform's primary no-replace primitive -// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than -// the destination existing. It stages a full copy under a private name next -// to reclaimed and publishes it to path with publish (the same no-replace -// primitive the caller's platform uses for the primary restore), so path -// never appears in a partially-copied state. publish keeps the no-overwrite -// guarantee: a new holder that appeared in the meantime wins and this returns -// os.ErrExist. The copy resets the lock's mtime to now, which only makes the -// restored lock look fresher; that is safe, since it is being handed back to -// a live holder. -func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error { - staged := reclaimed + ".copy" - src, err := os.Open(reclaimed) - if err != nil { - return err - } - dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - _ = src.Close() - return err - } - _, err = io.Copy(dst, src) - // Close the source before removing anything below: Go opens files without - // FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src - // is open would fail with a sharing violation. - _ = src.Close() - if err != nil { - _ = dst.Close() - _ = os.Remove(staged) - return err - } - if err := dst.Close(); err != nil { - _ = os.Remove(staged) - return err - } - if err := publish(staged, path); err != nil { - _ = os.Remove(staged) - return err - } - _ = os.Remove(staged) - _ = RemoveLockFile(reclaimed) - return nil -} diff --git a/internal/lockutil/lockutil_other.go b/internal/lockutil/lockutil_other.go deleted file mode 100644 index a8d03e059..000000000 --- a/internal/lockutil/lockutil_other.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build !windows - -package lockutil - -import ( - "errors" - "io/fs" - "os" -) - -// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It -// uses os.Link so a competing lock created at path in the meantime wins: the -// link fails with os.ErrExist instead of overwriting it. If the link fails for -// any other reason (hardlink-incapable filesystems such as FAT or some -// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather -// than leaving path missing and the live holder's lock stranded in reclaimed. -// Once the lock is back at path the restore has succeeded, so a failed cleanup -// of the sidelined name is not reported as an error. -func RestoreLockFile(reclaimed, path string) error { - err := os.Link(reclaimed, path) - if err == nil { - _ = RemoveLockFile(reclaimed) - return nil - } - if errors.Is(err, os.ErrExist) { - return err - } - return restoreByCopy(reclaimed, path, os.Link) -} - -// isReclaimContended reports whether a failed rename-aside of a suspected -// stale lock means it was lost to a racer rather than a hard failure. POSIX -// rename has no contention errno (a lost race surfaces only as ENOENT, which -// ReclaimStaleLock already treats as benign), so nothing extra is benign here. -func isReclaimContended(error) bool { return false } - -// RemoveLockFile removes a lock file on non-Windows platforms. Removing an -// already-missing file reports nil, matching the Windows implementation, so -// callers see one cross-platform contract. -func RemoveLockFile(path string) error { - if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - return nil -} diff --git a/internal/lockutil/lockutil_windows.go b/internal/lockutil/lockutil_windows.go deleted file mode 100644 index f4091196d..000000000 --- a/internal/lockutil/lockutil_windows.go +++ /dev/null @@ -1,75 +0,0 @@ -//go:build windows - -package lockutil - -import ( - "errors" - "os" - "syscall" - "time" - - "golang.org/x/sys/windows" -) - -// RestoreLockFile restores a sidelined lock file on Windows. It moves the file -// with a no-replace MoveFileEx so a competing lock created at path in the -// meantime wins: the move fails with os.ErrExist instead of overwriting it. If -// the move fails for any other reason (ERROR_SHARING_VIOLATION or -// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to -// an O_EXCL copy rather than leaving path missing and the live holder's lock -// stranded in reclaimed. -func RestoreLockFile(reclaimed, path string) error { - err := moveFileNoReplace(reclaimed, path) - if err == nil || errors.Is(err, os.ErrExist) { - return err - } - return restoreByCopy(reclaimed, path, moveFileNoReplace) -} - -// moveFileNoReplace renames from to to, failing if to already exists. It calls -// MoveFileExW directly via golang.org/x/sys/windows (the standard library's -// syscall package does not export MoveFileEx on this platform) with no flags, -// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies -// errors.Is(err, os.ErrExist), instead of overwriting it the way os.Rename does -// on Windows. -func moveFileNoReplace(from, to string) error { - fromPtr, err := windows.UTF16PtrFromString(from) - if err != nil { - return err - } - toPtr, err := windows.UTF16PtrFromString(to) - if err != nil { - return err - } - return windows.MoveFileEx(fromPtr, toPtr, 0) -} - -// isReclaimContended reports whether a failed rename-aside of a suspected -// stale lock means the file was concurrently open or pending deletion (which -// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows) -// rather than a hard failure. -func isReclaimContended(err error) bool { - var errno syscall.Errno - return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) -} - -// RemoveLockFile removes a lock file on Windows, retrying on the sharing -// violation or access denied errors that are common under heavy concurrent -// contention. Removing an already-missing file reports nil, matching the -// non-Windows implementation, so callers see one cross-platform contract. -func RemoveLockFile(path string) error { - var err error - for i := 0; i < 15; i++ { - err = os.Remove(path) - if err == nil || os.IsNotExist(err) { - return nil - } - var errno syscall.Errno - if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) { - time.Sleep(5 * time.Millisecond) - continue - } - return err - } - return err -} diff --git a/internal/lockutil/reclaim.go b/internal/lockutil/reclaim.go deleted file mode 100644 index 2c20778f9..000000000 --- a/internal/lockutil/reclaim.go +++ /dev/null @@ -1,59 +0,0 @@ -package lockutil - -import ( - "errors" - "os" -) - -// restoreLockFile is swappable so tests can force the fail-closed path of -// ReclaimStaleLock, which requires both the fast restore and its no-replace -// fallback (with its own copy fallback) to fail; that cannot be provoked -// portably on a healthy filesystem. -var restoreLockFile = restoreLiveLock - -// restoreLiveLock puts a lock that turned out to be live back at path after -// ReclaimStaleLock moved it aside to inspect it. It first tries a fast, -// replacing rename straight from reclaimed to path: a single syscall, which -// keeps the window during which path does not exist as short as possible. -// RestoreLockFile's no-replace restore is a correctness-preserving fallback -// for when the fast path itself fails. -func restoreLiveLock(reclaimed, path string) error { - if err := os.Rename(reclaimed, path); err == nil { - return nil - } - return RestoreLockFile(reclaimed, path) -} - -// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames -// lockPath aside to ".stale." (only one racer can win the -// rename of a given file, so two racers can never both reclaim the same lock), -// then consults isLive on the moved file; if the lock turns out to be live (a -// holder reacquired it in the gap between the caller's stale check and the -// rename) it is restored rather than stolen. The suffix must be unique per -// acquirer attempt. Returns true only when a genuinely stale lock was removed, -// so the caller knows it is safe to retry its exclusive create immediately; on -// a lost race it returns false. A non-nil error means either the rename aside -// failed for a reason that is not contention, or a live holder's lock could not -// be restored, so lockPath may be missing; callers must fail closed instead of -// re-acquiring. The sidelined file is removed on every restore failure. -func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) { - reclaimed := lockPath + ".stale." + suffix - if err := os.Rename(lockPath, reclaimed); err != nil { - if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) { - return false, nil // another racer already moved/removed it, or it vanished - } - return false, err - } - if isLive(reclaimed) { - // Put the live lock back instead of stealing it, and let the caller wait. - if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil { - _ = RemoveLockFile(reclaimed) - if !errors.Is(rerr, os.ErrExist) { - return false, rerr - } - } - return false, nil - } - _ = RemoveLockFile(reclaimed) - return true, nil -} diff --git a/internal/lockutil/reclaim_test.go b/internal/lockutil/reclaim_test.go deleted file mode 100644 index e2ede7f46..000000000 --- a/internal/lockutil/reclaim_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package lockutil - -import ( - "errors" - "os" - "path/filepath" - "testing" -) - -func TestReclaimStaleLockRemovesStale(t *testing.T) { - dir := t.TempDir() - lock := filepath.Join(dir, "demo.lock") - if err := os.WriteFile(lock, []byte("holder"), 0o600); err != nil { - t.Fatal(err) - } - reclaimed, err := ReclaimStaleLock(lock, "abc", func(string) bool { return false }) - if err != nil { - t.Fatal(err) - } - if !reclaimed { - t.Fatal("expected stale lock to be reclaimed") - } - if _, err := os.Stat(lock); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("lock should be gone after reclaim, stat err = %v", err) - } -} - -func TestReclaimStaleLockRestoresLive(t *testing.T) { - dir := t.TempDir() - lock := filepath.Join(dir, "demo.lock") - if err := os.WriteFile(lock, []byte("holder"), 0o600); err != nil { - t.Fatal(err) - } - reclaimed, err := ReclaimStaleLock(lock, "abc", func(string) bool { return true }) - if err != nil { - t.Fatal(err) - } - if reclaimed { - t.Fatal("a live lock must not be reported as reclaimed") - } - data, err := os.ReadFile(lock) - if err != nil { - t.Fatalf("live lock not restored: %v", err) - } - if string(data) != "holder" { - t.Fatalf("restored lock content = %q, want holder", data) - } -} - -func TestReclaimStaleLockMissingIsLostRace(t *testing.T) { - dir := t.TempDir() - lock := filepath.Join(dir, "nope.lock") - reclaimed, err := ReclaimStaleLock(lock, "abc", func(string) bool { return false }) - if err != nil { - t.Fatal(err) - } - if reclaimed { - t.Fatal("missing lock must not be reported as reclaimed") - } -} - -func TestReclaimStaleLockRestoreFailureFailsClosed(t *testing.T) { - dir := t.TempDir() - lock := filepath.Join(dir, "demo.lock") - if err := os.WriteFile(lock, []byte("holder"), 0o600); err != nil { - t.Fatal(err) - } - orig := restoreLockFile - restoreLockFile = func(string, string) error { return errors.New("restore boom") } - defer func() { restoreLockFile = orig }() - - if _, err := ReclaimStaleLock(lock, "abc", func(string) bool { return true }); err == nil { - t.Fatal("expected error when restore fails") - } -} - -func TestRestoreLockFileNoOverwrite(t *testing.T) { - dir := t.TempDir() - reclaimed := filepath.Join(dir, "reclaimed") - path := filepath.Join(dir, "target") - if err := os.WriteFile(reclaimed, []byte("r"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("new-holder"), 0o600); err != nil { - t.Fatal(err) - } - err := RestoreLockFile(reclaimed, path) - if !errors.Is(err, os.ErrExist) { - t.Fatalf("expected os.ErrExist, got %v", err) - } - data, _ := os.ReadFile(path) - if string(data) != "new-holder" { - t.Fatalf("competing holder overwritten: %q", data) - } -} - -func TestRemoveLockFileMissingIsNoop(t *testing.T) { - if err := RemoveLockFile(filepath.Join(t.TempDir(), "missing.lock")); err != nil { - t.Fatalf("expected no-op, got %v", err) - } -} diff --git a/internal/magicdocs/auto_update.go b/internal/magicdocs/auto_update.go deleted file mode 100644 index 5918ce6da..000000000 --- a/internal/magicdocs/auto_update.go +++ /dev/null @@ -1,117 +0,0 @@ -package magicdocs - -import ( - "bufio" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "time" -) - -const ( - magicDocMarker = "# MAGIC DOC:" - staleDuration = 24 * time.Hour -) - -// MagicDocFile represents a file containing auto-update markers. -type MagicDocFile struct { - Path string - Title string - LastUpdated time.Time -} - -// ScanForMagicDocs finds files containing "# MAGIC DOC:" markers in the given -// directory tree. It skips hidden directories and common non-source paths. -func ScanForMagicDocs(dir string) []MagicDocFile { - var docs []MagicDocFile - - _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - base := filepath.Base(path) - - // Skip hidden directories and common non-source paths - if d.IsDir() { - if strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor" || base == "__pycache__" { - return filepath.SkipDir - } - return nil - } - - // Only scan text-like files - ext := filepath.Ext(path) - if !isTextExt(ext) { - return nil - } - - fi, fiErr := d.Info() - if fiErr != nil { - return fiErr - } - doc := scanFileForMarker(path, fi) - if doc != nil { - docs = append(docs, *doc) - } - - return nil - }) - - return docs -} - -func scanFileForMarker(path string, info os.FileInfo) *MagicDocFile { - f, err := os.Open(path) // #nosec G304 -- path comes from filepath.WalkDir over the project directory being scanned, not external input - if err != nil { - return nil - } - defer func() { _ = f.Close() }() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if idx := strings.Index(line, magicDocMarker); idx >= 0 { - title := strings.TrimSpace(line[idx+len(magicDocMarker):]) - return &MagicDocFile{ - Path: path, - Title: title, - LastUpdated: info.ModTime(), - } - } - } - - return nil -} - -// NeedsUpdate returns true if the magic doc is stale (>24h since last update). -func (m *MagicDocFile) NeedsUpdate() bool { - return time.Since(m.LastUpdated) > staleDuration -} - -// GenerateUpdatePrompt creates the prompt for the LLM to regenerate the doc. -func (m *MagicDocFile) GenerateUpdatePrompt() string { - return fmt.Sprintf( - "Update the magic doc in %s (title: %q). "+ - "Read the current file content, analyze the surrounding codebase for changes since the doc was last updated (%s), "+ - "and regenerate the documentation section marked with '# MAGIC DOC: %s'. "+ - "Keep the same format and marker comments. Only update the content between the markers.", - m.Path, - m.Title, - m.LastUpdated.Format(time.RFC3339), - m.Title, - ) -} - -func isTextExt(ext string) bool { - textExts := map[string]bool{ - ".go": true, ".md": true, ".txt": true, ".py": true, - ".js": true, ".ts": true, ".tsx": true, ".jsx": true, - ".rs": true, ".rb": true, ".java": true, ".c": true, - ".h": true, ".cpp": true, ".yaml": true, ".yml": true, - ".toml": true, ".json": true, ".sh": true, ".bash": true, - } - return textExts[ext] -} diff --git a/internal/magicdocs/auto_update_test.go b/internal/magicdocs/auto_update_test.go deleted file mode 100644 index de5605874..000000000 --- a/internal/magicdocs/auto_update_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package magicdocs - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestScanForMagicDocs_FindsMarker(t *testing.T) { - dir := t.TempDir() - content := `package main - -// # MAGIC DOC: API Reference -// This section is auto-generated. -func main() {} -` - os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o644) - - docs := ScanForMagicDocs(dir) - if len(docs) != 1 { - t.Fatalf("expected 1 magic doc, got %d", len(docs)) - } - if docs[0].Title != "API Reference" { - t.Fatalf("expected title 'API Reference', got %q", docs[0].Title) - } -} - -func TestScanForMagicDocs_NoMarker(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644) - - docs := ScanForMagicDocs(dir) - if len(docs) != 0 { - t.Fatalf("expected 0 magic docs, got %d", len(docs)) - } -} - -func TestScanForMagicDocs_SkipsHidden(t *testing.T) { - dir := t.TempDir() - hiddenDir := filepath.Join(dir, ".hidden") - os.MkdirAll(hiddenDir, 0o755) - os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("// # MAGIC DOC: Hidden"), 0o644) - - docs := ScanForMagicDocs(dir) - if len(docs) != 0 { - t.Fatalf("expected 0 magic docs (hidden dir skipped), got %d", len(docs)) - } -} - -func TestMagicDocFile_NeedsUpdate(t *testing.T) { - fresh := &MagicDocFile{LastUpdated: time.Now()} - if fresh.NeedsUpdate() { - t.Fatal("fresh doc should not need update") - } - - stale := &MagicDocFile{LastUpdated: time.Now().Add(-48 * time.Hour)} - if !stale.NeedsUpdate() { - t.Fatal("stale doc should need update") - } -} - -func TestMagicDocFile_GenerateUpdatePrompt(t *testing.T) { - doc := &MagicDocFile{ - Path: "/path/to/file.go", - Title: "API Reference", - LastUpdated: time.Now().Add(-48 * time.Hour), - } - - prompt := doc.GenerateUpdatePrompt() - if !strings.Contains(prompt, "API Reference") { - t.Fatalf("expected title in prompt, got %q", prompt) - } - if !strings.Contains(prompt, "/path/to/file.go") { - t.Fatalf("expected path in prompt, got %q", prompt) - } -} diff --git a/internal/magicdocs/magicdocs.go b/internal/magicdocs/magicdocs.go deleted file mode 100644 index 2cdf817ee..000000000 --- a/internal/magicdocs/magicdocs.go +++ /dev/null @@ -1,103 +0,0 @@ -package magicdocs - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "strings" -) - -// DocEntry represents a generated documentation entry. -type DocEntry struct { - Package string `json:"package"` - Name string `json:"name"` - Type string `json:"type"` // function, type, method, variable - Doc string `json:"doc"` - File string `json:"file"` - Line int `json:"line"` -} - -// Extract extracts documentation from Go source files. -func Extract(dir string) ([]DocEntry, error) { - var entries []DocEntry - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() || filepath.Ext(path) != ".go" { - return nil - } - if strings.HasSuffix(path, "_test.go") { - return nil - } - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil // skip unparseable files - } - for _, decl := range f.Decls { - switch d := decl.(type) { - case *ast.FuncDecl: - name := d.Name.Name - doc := "" - if d.Doc != nil { - doc = d.Doc.Text() - } - entries = append(entries, DocEntry{ - Package: f.Name.Name, - Name: name, - Type: "function", - Doc: doc, - File: path, - Line: fset.Position(d.Pos()).Line, - }) - case *ast.GenDecl: - if d.Tok == token.TYPE { - for _, spec := range d.Specs { - ts, _ := spec.(*ast.TypeSpec) - doc := "" - if d.Doc != nil { - doc = d.Doc.Text() - } - entries = append(entries, DocEntry{ - Package: f.Name.Name, - Name: ts.Name.Name, - Type: "type", - Doc: doc, - File: path, - Line: fset.Position(ts.Pos()).Line, - }) - } - } - } - } - return nil - }) - return entries, err -} - -// GenerateMarkdown generates markdown documentation. -func GenerateMarkdown(entries []DocEntry) string { - var b strings.Builder - b.WriteString("# API Documentation\n\n") - packages := make(map[string][]DocEntry) - for _, e := range entries { - packages[e.Package] = append(packages[e.Package], e) - } - for pkg, pkgEntries := range packages { - _, _ = fmt.Fprintf(&b, "## Package %s\n\n", pkg) - for _, e := range pkgEntries { - _, _ = fmt.Fprintf(&b, "### %s\n\n", e.Name) - _, _ = fmt.Fprintf(&b, "- **Type**: %s\n", e.Type) - _, _ = fmt.Fprintf(&b, "- **File**: %s:%d\n\n", e.File, e.Line) - if e.Doc != "" { - b.WriteString(e.Doc) - b.WriteString("\n\n") - } - } - } - return b.String() -} diff --git a/internal/magicdocs/magicdocs_test.go b/internal/magicdocs/magicdocs_test.go deleted file mode 100644 index a466d1e5b..000000000 --- a/internal/magicdocs/magicdocs_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package magicdocs - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestExtract(t *testing.T) { - dir := t.TempDir() - code := `package main - -// Hello says hello. -func Hello(name string) string { - return "Hello " + name -} - -// Person represents a person. -type Person struct { - Name string -} -` - if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0o644); err != nil { - t.Fatal(err) - } - - entries, err := Extract(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - - foundHello := false - foundPerson := false - for _, e := range entries { - if e.Name == "Hello" && e.Type == "function" { - foundHello = true - if !strings.Contains(e.Doc, "says hello") { - t.Fatalf("expected doc to contain 'says hello', got %q", e.Doc) - } - } - if e.Name == "Person" && e.Type == "type" { - foundPerson = true - } - } - if !foundHello { - t.Fatal("expected Hello function") - } - if !foundPerson { - t.Fatal("expected Person type") - } -} - -func TestGenerateMarkdown(t *testing.T) { - entries := []DocEntry{ - {Package: "main", Name: "Hello", Type: "function", Doc: "Says hello.\n"}, - {Package: "main", Name: "World", Type: "function", Doc: "Says world.\n"}, - } - - md := GenerateMarkdown(entries) - if !strings.Contains(md, "# API Documentation") { - t.Fatal("expected markdown header") - } - if !strings.Contains(md, "Hello") { - t.Fatal("expected Hello in markdown") - } -} diff --git a/internal/observability/alerts/cooldown.go b/internal/observability/alerts/cooldown.go deleted file mode 100644 index 9a18ef598..000000000 --- a/internal/observability/alerts/cooldown.go +++ /dev/null @@ -1,138 +0,0 @@ -package alerts - -import ( - "sync" - "time" -) - -type Alert struct { - ID string `json:"id"` - Type string `json:"type"` - Entity string `json:"entity"` - Message string `json:"message"` - CreatedAt time.Time `json:"created_at"` - Delivered bool `json:"delivered"` -} - -type CooldownConfig struct { - Period time.Duration `json:"period"` - MaxPending int `json:"max_pending"` - DrainInterval time.Duration `json:"drain_interval"` - SendDelay time.Duration `json:"send_delay"` -} - -func DefaultCooldownConfig() CooldownConfig { - return CooldownConfig{ - Period: 24 * time.Hour, - MaxPending: 100, - DrainInterval: 5 * time.Minute, - SendDelay: time.Second, - } -} - -type AlertQueue struct { - mu sync.Mutex - pending []*Alert - cooldowns map[string]time.Time // entity+type -> last sent - config CooldownConfig - handler func(*Alert) error - stopCh chan struct{} -} - -func NewAlertQueue(config CooldownConfig, handler func(*Alert) error) *AlertQueue { - return &AlertQueue{ - pending: make([]*Alert, 0), - cooldowns: make(map[string]time.Time), - config: config, - handler: handler, - stopCh: make(chan struct{}), - } -} - -func (q *AlertQueue) Enqueue(alert *Alert) bool { - q.mu.Lock() - defer q.mu.Unlock() - - key := alert.Entity + ":" + alert.Type - if last, ok := q.cooldowns[key]; ok && time.Since(last) < q.config.Period { - return false - } - - if len(q.pending) >= q.config.MaxPending { - return false - } - - alert.CreatedAt = time.Now() - q.pending = append(q.pending, alert) - return true -} - -func (q *AlertQueue) Start() { - go q.drainLoop() -} - -func (q *AlertQueue) Stop() { - close(q.stopCh) -} - -func (q *AlertQueue) drainLoop() { - ticker := time.NewTicker(q.config.DrainInterval) - defer ticker.Stop() - - for { - select { - case <-q.stopCh: - return - case <-ticker.C: - q.drain() - } - } -} - -func (q *AlertQueue) drain() { - q.mu.Lock() - batch := q.pending - q.pending = make([]*Alert, 0) - q.mu.Unlock() - - // requeueRemaining puts the not-yet-delivered tail of the batch back so - // a Stop() mid-drain never drops alerts that were never sent. - requeueRemaining := func(from int) { - if from >= len(batch) { - return - } - remainder := batch[from:] - q.mu.Lock() - q.pending = append(remainder, q.pending...) - q.mu.Unlock() - } - - for i, alert := range batch { - select { - case <-q.stopCh: - requeueRemaining(i) - return - default: - } - if q.handler != nil { - if err := q.handler(alert); err == nil { - alert.Delivered = true - q.mu.Lock() - q.cooldowns[alert.Entity+":"+alert.Type] = time.Now() - q.mu.Unlock() - } - } - select { - case <-q.stopCh: - requeueRemaining(i + 1) - return - case <-time.After(q.config.SendDelay): - } - } -} - -func (q *AlertQueue) PendingCount() int { - q.mu.Lock() - defer q.mu.Unlock() - return len(q.pending) -} diff --git a/internal/observability/alerts/cooldown_test.go b/internal/observability/alerts/cooldown_test.go deleted file mode 100644 index 2590c0626..000000000 --- a/internal/observability/alerts/cooldown_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package alerts - -import ( - "errors" - "sync/atomic" - "testing" - "time" -) - -func TestDefaultCooldownConfig(t *testing.T) { - c := DefaultCooldownConfig() - if c.Period != 24*time.Hour { - t.Errorf("Period = %v, want 24h", c.Period) - } - if c.MaxPending != 100 { - t.Errorf("MaxPending = %d, want 100", c.MaxPending) - } - if c.DrainInterval != 5*time.Minute { - t.Errorf("DrainInterval = %v, want 5m", c.DrainInterval) - } - if c.SendDelay != time.Second { - t.Errorf("SendDelay = %v, want 1s", c.SendDelay) - } -} - -func TestEnqueueBasic(t *testing.T) { - q := NewAlertQueue(DefaultCooldownConfig(), nil) - ok := q.Enqueue(&Alert{ID: "1", Type: "error", Entity: "svc"}) - if !ok { - t.Fatal("expected enqueue to succeed") - } - if q.PendingCount() != 1 { - t.Fatalf("PendingCount = %d, want 1", q.PendingCount()) - } -} - -func TestEnqueueCooldown(t *testing.T) { - cfg := CooldownConfig{ - Period: time.Hour, - MaxPending: 100, - } - handler := func(a *Alert) error { return nil } - q := NewAlertQueue(cfg, handler) - - // First enqueue succeeds. - if !q.Enqueue(&Alert{ID: "1", Type: "error", Entity: "svc"}) { - t.Fatal("first enqueue should succeed") - } - - // Drain to trigger cooldown tracking. - q.drain() - - // Second enqueue for same entity:type should be rejected (cooldown). - if q.Enqueue(&Alert{ID: "2", Type: "error", Entity: "svc"}) { - t.Fatal("second enqueue should be rejected by cooldown") - } - - // Different type should succeed. - if !q.Enqueue(&Alert{ID: "3", Type: "warn", Entity: "svc"}) { - t.Fatal("enqueue for different type should succeed") - } -} - -func TestEnqueueMaxPending(t *testing.T) { - cfg := CooldownConfig{ - Period: time.Hour, - MaxPending: 2, - } - q := NewAlertQueue(cfg, nil) - - if !q.Enqueue(&Alert{ID: "1", Type: "a", Entity: "e1"}) { - t.Fatal("enqueue 1 should succeed") - } - if !q.Enqueue(&Alert{ID: "2", Type: "a", Entity: "e2"}) { - t.Fatal("enqueue 2 should succeed") - } - if q.Enqueue(&Alert{ID: "3", Type: "a", Entity: "e3"}) { - t.Fatal("enqueue 3 should fail (max pending)") - } -} - -func TestDrain(t *testing.T) { - cfg := CooldownConfig{ - Period: time.Hour, - MaxPending: 100, - SendDelay: 0, - } - var delivered atomic.Int32 - handler := func(a *Alert) error { - delivered.Add(1) - return nil - } - q := NewAlertQueue(cfg, handler) - - q.Enqueue(&Alert{ID: "1", Type: "t", Entity: "e"}) - q.Enqueue(&Alert{ID: "2", Type: "t", Entity: "e2"}) - q.drain() - - if delivered.Load() != 2 { - t.Errorf("delivered = %d, want 2", delivered.Load()) - } - if q.PendingCount() != 0 { - t.Errorf("PendingCount = %d, want 0 after drain", q.PendingCount()) - } -} - -func TestDrainHandlerError(t *testing.T) { - cfg := CooldownConfig{ - Period: time.Hour, - MaxPending: 100, - SendDelay: 0, - } - handler := func(a *Alert) error { - return errors.New("send failed") - } - q := NewAlertQueue(cfg, handler) - - q.Enqueue(&Alert{ID: "1", Type: "t", Entity: "e"}) - q.drain() - - // Failed delivery should not set cooldown, so a second enqueue should succeed. - if !q.Enqueue(&Alert{ID: "2", Type: "t", Entity: "e"}) { - t.Error("second enqueue should succeed since cooldown was not set for failed delivery") - } -} - -func TestStartStop(t *testing.T) { - cfg := CooldownConfig{ - Period: time.Hour, - MaxPending: 100, - DrainInterval: 10 * time.Millisecond, - SendDelay: 0, - } - var delivered atomic.Int32 - handler := func(a *Alert) error { - delivered.Add(1) - return nil - } - q := NewAlertQueue(cfg, handler) - q.Enqueue(&Alert{ID: "1", Type: "t", Entity: "e"}) - - q.Start() - time.Sleep(50 * time.Millisecond) - q.Stop() - - if delivered.Load() < 1 { - t.Error("expected at least 1 delivery after start") - } -} diff --git a/internal/observability/conformance/conformance_test.go b/internal/observability/conformance/conformance_test.go deleted file mode 100644 index 05fee2617..000000000 --- a/internal/observability/conformance/conformance_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package conformance - -import ( - "context" - "strings" - "testing" - - "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" -) - -// hawkSpanProducers wires the real span starters from internal/observability/ -// oteltrace into the conformance harness. Keeping the starters as the producers -// means the test fails if a starter stops setting a required attribute. -func hawkSpanProducers() []SpanProducer { - return []SpanProducer{ - func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span { - _, span := oteltrace.StartAgentLoopSpan(ctx, t, "anthropic", "claude-sonnet-4", 3) - span.Finish() - return span - }, - func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span { - _, span := oteltrace.StartToolSpan(ctx, t, "Read", "tool-1") - span.Finish() - return span - }, - func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span { - _, span := oteltrace.StartCompactSpan(ctx, t, "summary", 1200) - span.Finish() - return span - }, - func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span { - _, span := oteltrace.StartAPICallSpan(ctx, t, "openai", "gpt-4o") - span.Finish() - return span - }, - func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span { - _, span := oteltrace.StartSessionSpan(ctx, t, "sess-123") - span.Finish() - return span - }, - } -} - -func TestHawkSpansConformToSchema(t *testing.T) { - report := Run(HawkSchema, hawkSpanProducers()) - if !report.Passed() { - for _, f := range report.Findings { - t.Errorf("conformance finding: %s: %s", f.SpanName, f.Message) - } - t.Fatalf("hawk spans must conform; %d violations", report.Violated) - } -} - -// TestSchemaCatchesDrift verifies the schema itself is strict: a span missing a -// required attribute or carrying a sensitive key must fail validation. This -// guards the conformance suite against becoming a no-op. -func TestSchemaCatchesDrift(t *testing.T) { - schema := HawkSchema - - // Missing required attribute must fail. - if findings := schema.Validate("agent_loop", map[string]string{"provider": "anthropic"}); len(findings) == 0 { - t.Fatal("agent_loop without model/message_count must be a violation") - } - - // Unknown span must fail. - if findings := schema.Validate("does_not_exist", map[string]string{}); len(findings) == 0 { - t.Fatal("unknown span must be a violation") - } - - // Sensitive content-bearing attribute must fail. - if findings := schema.Validate("agent_loop", map[string]string{ - "provider": "anthropic", "model": "m", "message_count": "1", "prompt": "secret", - }); len(findings) == 0 { - t.Fatal("span carrying raw prompt content must be a violation") - } - - // Valid spans pass. - if findings := schema.Validate("tool.Read", map[string]string{"tool.name": "Read", "tool.id": "1"}); len(findings) != 0 { - t.Fatalf("valid tool span should pass, got %+v", findings) - } -} - -// TestRunIsPassive verifies the harness never panics even when a producer panics. -func TestRunIsPassive(t *testing.T) { - producers := []SpanProducer{ - func(context.Context, *oteltrace.Tracer) *oteltrace.Span { - panic("boom") - }, - } - report := Run(HawkSchema, producers) - if !report.Passed() { - if !strings.Contains(report.Findings[0].Message, "panicked") { - t.Fatalf("expected a panicked finding, got %+v", report.Findings) - } - } -} diff --git a/internal/observability/conformance/harness.go b/internal/observability/conformance/harness.go deleted file mode 100644 index bd8d6083b..000000000 --- a/internal/observability/conformance/harness.go +++ /dev/null @@ -1,60 +0,0 @@ -package conformance - -import ( - "context" - - "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" -) - -// SpanProducer starts and finishes one span on the given tracer, returning the -// finished span. It is the seam that connects a real span starter to the -// conformance harness. -type SpanProducer func(ctx context.Context, t *oteltrace.Tracer) *oteltrace.Span - -// Report summarizes the conformance result for a batch of spans. -type Report struct { - Total int `json:"total"` - Violated int `json:"violated"` - Findings []Finding `json:"findings,omitempty"` -} - -// Passed reports whether every span conformed. -func (r Report) Passed() bool { return r.Violated == 0 } - -// Run executes the given producers against a fresh tracer, validates every -// recorded span against the schema, and returns a report. It is passive: a -// producer that panics is caught and reported as a finding rather than crashing -// the harness, so CI can surface drift without breaking the run. -func Run(schema Schema, producers []SpanProducer) Report { - t := oteltrace.NewTracer() - defer t.Clear() - - var report Report - for _, produce := range producers { - span := safeProduce(produce, t) - if span == nil { - report.Total++ - report.Violated++ - report.Findings = append(report.Findings, Finding{SpanName: "", Message: "span producer panicked"}) - continue - } - report.Total++ - findings := schema.Validate(span.Name, span.Tags) - if len(findings) > 0 { - report.Violated++ - report.Findings = append(report.Findings, findings...) - } - } - return report -} - -// safeProduce invokes a producer, recovering any panic so a misbehaving span -// starter cannot break the conformance run. It returns nil on panic. -func safeProduce(produce SpanProducer, t *oteltrace.Tracer) (span *oteltrace.Span) { - defer func() { - if r := recover(); r != nil { - span = nil - } - }() - return produce(context.Background(), t) -} diff --git a/internal/observability/conformance/schema.go b/internal/observability/conformance/schema.go deleted file mode 100644 index 620a9f1dd..000000000 --- a/internal/observability/conformance/schema.go +++ /dev/null @@ -1,148 +0,0 @@ -// Package conformance verifies that emitted telemetry spans always match the -// documented OpenTelemetry schema (docs/OTEL-CONVENTIONS.md and the eyrie -// gen_ai.* semantic-convention constants), so the schema cannot silently drift -// across Hawk and its independent ecosystem repositories. -// -// The schema is declarative and typed per span: a span name (or prefix -// pattern), the required and optional attribute keys, and whether the span is -// forbidden from carrying raw prompt/response content. A Harness runs a set of -// span producers through a tracer and validates every recorded span against the -// schema. Validation is passive and non-throwing: a malformed or unknown span -// produces a finding, never a panic, so it can be used in CI to gate drift. -package conformance - -import ( - "strings" -) - -// Attribute vocabulary shared across the ecosystem. These mirror the keys -// emitted by the span starters in internal/observability/oteltrace and the -// gen_ai.* constants in eyrie/internal/observability. -const ( - AttrGenAISystem = "gen_ai.system" - AttrGenAIRequestModel = "gen_ai.request.model" - AttrGenAIResponseModel = "gen_ai.response.model" - AttrGenAIUsageInputTokens = "gen_ai.usage.input_tokens" // #nosec G101 -- OTel semconv attribute key string, not a secret value - AttrGenAIUsageOutputTokens = "gen_ai.usage.output_tokens" // #nosec G101 -- OTel semconv attribute key string, not a secret value - AttrGenAIOperationName = "gen_ai.operation.name" - AttrCostUSD = "cost.usd" - AttrToolName = "tool.name" - AttrSessionID = "session.id" - AttrAgentID = "agent.id" -) - -// sensitiveKeySubstrings identify attribute keys that may carry raw content -// (prompt/response/chat text) and are therefore forbidden by the no-raw-content -// rule unless explicitly allow-listed. The documented contract says spans MUST -// NOT carry raw prompt/response text. -var sensitiveKeySubstrings = []string{"prompt", "response", "content", "text"} - -// SpanDef describes one span (or a family matched by NamePattern) in the schema. -type SpanDef struct { - // NamePattern is the exact span name or a "*"-suffixed prefix pattern - // (e.g. "tool.*" matches "tool.Read"). If it does not end in "*", it is an - // exact match. - NamePattern string - // RequiredAttrs lists attribute keys that must be present. - RequiredAttrs []string - // OptionalAttrs lists attribute keys that may be present but are not required. - OptionalAttrs []string -} - -// Schema is the ordered set of span definitions. -type Schema []SpanDef - -// HawkSchema is the schema covering every span hawk emits via the starters in -// internal/observability/oteltrace/spans.go. -var HawkSchema = Schema{ - {NamePattern: "agent_loop", RequiredAttrs: []string{"provider", "model", "message_count"}}, - {NamePattern: "tool.*", RequiredAttrs: []string{"tool.name", "tool.id"}}, - {NamePattern: "compact.*", RequiredAttrs: []string{"compact.strategy", "compact.tokens_before"}}, - {NamePattern: "api.chat", RequiredAttrs: []string{"api.provider", "api.model"}}, - {NamePattern: "session", RequiredAttrs: []string{"session.id"}}, -} - -// Finding describes a single conformance violation. -type Finding struct { - SpanName string `json:"span_name"` - Message string `json:"message"` -} - -// Find returns the SpanDef whose pattern matches spanName, or nil. -func (s Schema) Find(spanName string) *SpanDef { - for i := range s { - if matches(s[i].NamePattern, spanName) { - return &s[i] - } - } - return nil -} - -// Validate checks a single span (name + attributes) against the schema. It -// returns every violation found. It never panics. -func (s Schema) Validate(spanName string, attributes map[string]string) []Finding { - var findings []Finding - def := s.Find(spanName) - if def == nil { - return append(findings, Finding{SpanName: spanName, Message: "span is not covered by the telemetry schema"}) - } - for _, key := range def.RequiredAttrs { - if v, ok := attributes[key]; !ok || strings.TrimSpace(v) == "" { - findings = append(findings, Finding{SpanName: spanName, Message: "missing required attribute " + key}) - } - } - for key := range attributes { - if !isAllowedKey(def, key) { - findings = append(findings, Finding{SpanName: spanName, Message: "attribute " + key + " is not declared for this span"}) - } - if isSensitiveKey(key) { - findings = append(findings, Finding{SpanName: spanName, Message: "span carries sensitive content-bearing attribute " + key}) - } - } - return findings -} - -// allowed reports whether key is a declared required or optional attribute. -func isAllowedKey(def *SpanDef, key string) bool { - for _, r := range def.RequiredAttrs { - if r == key { - return true - } - } - for _, o := range def.OptionalAttrs { - if o == key { - return true - } - } - // Standard span attributes such as error/error.message are permitted. - switch key { - case "error", "error.message": - return true - } - return false -} - -// isSensitiveKey reports whether an attribute key may carry raw content. -func isSensitiveKey(key string) bool { - lower := strings.ToLower(key) - // Counters and usage markers are numeric, not content. - if strings.HasSuffix(lower, "_count") || - strings.HasSuffix(lower, "_tokens") || - strings.HasSuffix(lower, "_usage") || - lower == "message_count" { - return false - } - for _, s := range sensitiveKeySubstrings { - if strings.Contains(lower, s) { - return true - } - } - return false -} - -func matches(pattern, name string) bool { - if strings.HasSuffix(pattern, "*") { - return strings.HasPrefix(name, strings.TrimSuffix(pattern, "*")) - } - return pattern == name -} diff --git a/internal/observability/logfield/fields.go b/internal/observability/logfield/fields.go deleted file mode 100644 index cd663b049..000000000 --- a/internal/observability/logfield/fields.go +++ /dev/null @@ -1,147 +0,0 @@ -package logfield - -import ( - "errors" - "sort" - "strings" -) - -var ( - ErrInvalidKey = errors.New("invalid key: must be [A-Za-z0-9_]+") - ErrInvalidValue = errors.New("invalid value: must not contain spaces or '='") -) - -// Fields is a validated key=value annotation set. -type Fields struct { - m map[string]string -} - -func New(capacity int) *Fields { - return &Fields{m: make(map[string]string, capacity)} -} - -func (f *Fields) Put(key, value string) error { - if !isValidKey(key) { - return ErrInvalidKey - } - if !isValidValue(value) { - return ErrInvalidValue - } - f.m[key] = value - return nil -} - -// MustPut is a convenience method that panics on validation errors. -// Use Put() for error handling. This follows the Go pattern of Must* functions -// (e.g., regexp.MustCompile) for use in package-level initializers. -func (f *Fields) MustPut(key, value string) *Fields { - if err := f.Put(key, value); err != nil { - panic("logfield: " + err.Error()) - } - return f -} - -func (f *Fields) Get(key string) (string, bool) { - v, ok := f.m[key] - return v, ok -} - -func (f *Fields) String() string { - if len(f.m) == 0 { - return "" - } - var b strings.Builder - b.Grow(len(f.m) * 16) - first := true - for k, v := range f.m { - if !first { - b.WriteByte(' ') - } - first = false - b.WriteString(k) - b.WriteByte('=') - b.WriteString(v) - } - return b.String() -} - -func (f *Fields) StringSorted() string { - if len(f.m) == 0 { - return "" - } - keys := make([]string, 0, len(f.m)) - for k := range f.m { - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - b.Grow(len(keys) * 16) - for i, k := range keys { - if i > 0 { - b.WriteByte(' ') - } - b.WriteString(k) - b.WriteByte('=') - b.WriteString(f.m[k]) - } - return b.String() -} - -func Parse(s string, capacity int) (*Fields, error) { - f := New(capacity) - n := len(s) - i := 0 - for i < n { - for i < n && s[i] == ' ' { - i++ - } - if i >= n { - break - } - start := i - for i < n && s[i] != '=' { - i++ - } - if i == start || i >= n { - return nil, ErrInvalidKey - } - key := s[start:i] - i++ - start = i - for i < n && s[i] != ' ' { - i++ - } - if i == start { - return nil, ErrInvalidValue - } - if err := f.Put(key, s[start:i]); err != nil { - return nil, err - } - } - return f, nil -} - -func isValidKey(s string) bool { - if len(s) == 0 { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') { - return false - } - } - return true -} - -func isValidValue(s string) bool { - if len(s) == 0 { - return false - } - for i := 0; i < len(s); i++ { - if s[i] == ' ' || s[i] == '=' { - return false - } - } - return true -} diff --git a/internal/observability/logfield/fields_test.go b/internal/observability/logfield/fields_test.go deleted file mode 100644 index c0c87e5fb..000000000 --- a/internal/observability/logfield/fields_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package logfield - -import ( - "testing" -) - -func TestNew(t *testing.T) { - f := New(4) - if f == nil { - t.Fatal("New returned nil") - } - if _, ok := f.Get("missing"); ok { - t.Error("empty Fields should not have any keys") - } -} - -func TestPutAndGet(t *testing.T) { - f := New(4) - if err := f.Put("key1", "val1"); err != nil { - t.Fatalf("Put: %v", err) - } - v, ok := f.Get("key1") - if !ok || v != "val1" { - t.Errorf("Get(key1) = %q, %v; want val1, true", v, ok) - } -} - -func TestPutInvalidKey(t *testing.T) { - f := New(4) - if err := f.Put("bad key", "v"); err != ErrInvalidKey { - t.Errorf("Put with space in key: err = %v, want ErrInvalidKey", err) - } - if err := f.Put("bad=val", "v"); err != ErrInvalidKey { - t.Errorf("Put with = in key: err = %v, want ErrInvalidKey", err) - } - if err := f.Put("", "v"); err != ErrInvalidKey { - t.Errorf("Put with empty key: err = %v, want ErrInvalidKey", err) - } -} - -func TestPutInvalidValue(t *testing.T) { - f := New(4) - if err := f.Put("k", "bad val"); err != ErrInvalidValue { - t.Errorf("Put with space in value: err = %v, want ErrInvalidValue", err) - } - if err := f.Put("k", "bad=val"); err != ErrInvalidValue { - t.Errorf("Put with = in value: err = %v, want ErrInvalidValue", err) - } - if err := f.Put("k", ""); err != ErrInvalidValue { - t.Errorf("Put with empty value: err = %v, want ErrInvalidValue", err) - } -} - -func TestMustPutPanics(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("MustPut should panic on invalid key") - } - }() - New(4).MustPut("bad key", "v") -} - -func TestMustPutSuccess(t *testing.T) { - f := New(4) - ret := f.MustPut("k", "v") - if ret != f { - t.Error("MustPut should return the same Fields") - } - v, _ := f.Get("k") - if v != "v" { - t.Errorf("Get(k) = %q, want v", v) - } -} - -func TestStringEmpty(t *testing.T) { - f := New(4) - if s := f.String(); s != "" { - t.Errorf("String() = %q, want empty", s) - } -} - -func TestStringSorted(t *testing.T) { - f := New(4) - f.Put("z", "1") - f.Put("a", "2") - f.Put("m", "3") - - sorted := f.StringSorted() - if sorted != "a=2 m=3 z=1" { - t.Errorf("StringSorted() = %q, want %q", sorted, "a=2 m=3 z=1") - } -} - -func TestStringNonDeterministic(t *testing.T) { - // String() iterates a map so order is not guaranteed, but all k=v should appear. - f := New(4) - f.Put("x", "1") - f.Put("y", "2") - s := f.String() - if len(s) == 0 { - t.Error("String() should not be empty") - } -} - -func TestParseValid(t *testing.T) { - f, err := Parse("a=1 b=hello c=test", 4) - if err != nil { - t.Fatalf("Parse: %v", err) - } - for _, tc := range []struct { - key, want string - }{ - {"a", "1"}, - {"b", "hello"}, - {"c", "test"}, - } { - v, ok := f.Get(tc.key) - if !ok || v != tc.want { - t.Errorf("Get(%s) = %q, %v; want %q, true", tc.key, v, ok, tc.want) - } - } -} - -func TestParseEmpty(t *testing.T) { - f, err := Parse("", 4) - if err != nil { - t.Fatalf("Parse empty: %v", err) - } - if s := f.String(); s != "" { - t.Errorf("String() = %q, want empty", s) - } -} - -func TestParseInvalidKey(t *testing.T) { - _, err := Parse("bad key=val", 4) - if err != ErrInvalidKey { - t.Errorf("Parse: err = %v, want ErrInvalidKey", err) - } -} - -func TestParseInvalidValue(t *testing.T) { - // Parse splits by space then validates each value. "key=" with empty value - // triggers ErrInvalidValue because the value is empty after the = sign. - _, err := Parse("key=", 4) - if err != ErrInvalidValue { - t.Errorf("Parse: err = %v, want ErrInvalidValue", err) - } -} - -func TestParseMissingEquals(t *testing.T) { - _, err := Parse("noequals", 4) - if err != ErrInvalidKey { - t.Errorf("Parse: err = %v, want ErrInvalidKey", err) - } -} - -func TestPutOverwrite(t *testing.T) { - f := New(4) - f.Put("k", "v1") - f.Put("k", "v2") - v, _ := f.Get("k") - if v != "v2" { - t.Errorf("Get(k) = %q, want v2 (overwrite)", v) - } -} diff --git a/internal/preset/preset.go b/internal/preset/preset.go deleted file mode 100644 index b0bca647b..000000000 --- a/internal/preset/preset.go +++ /dev/null @@ -1,258 +0,0 @@ -// Package preset implements agent preset configurations (DSH preset/agent-presets parity). -package preset - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" -) - -// Source represents where a preset was loaded from. -type Source string - -const ( - SourceBuiltin Source = "builtin" - SourceUser Source = "user" - SourceProject Source = "project" -) - -// Preset defines a reusable agent configuration manifest. -type Preset struct { - Name string `json:"name" yaml:"name"` - Description string `json:"description" yaml:"description"` - SystemPrompt string `json:"system_prompt,omitempty" yaml:"system_prompt,omitempty"` - Model string `json:"model,omitempty" yaml:"model,omitempty"` - SubagentType string `json:"subagent_type,omitempty" yaml:"subagent_type,omitempty"` - CapabilityMode string `json:"capability_mode,omitempty" yaml:"capability_mode,omitempty"` - SandboxMode string `json:"sandbox_mode,omitempty" yaml:"sandbox_mode,omitempty"` - Tools []string `json:"tools,omitempty" yaml:"tools,omitempty"` - DenyTools []string `json:"deny_tools,omitempty" yaml:"deny_tools,omitempty"` - Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` - Source Source `json:"source,omitempty" yaml:"source,omitempty"` -} - -// Registry manages loaded agent presets with scope-based precedence (project > user > builtin). -type Registry struct { - mu sync.RWMutex - presets map[string]Preset -} - -var ( - defaultRegistry *Registry - defaultRegistryOnce sync.Once -) - -// Default returns the process-wide preset registry loaded with built-ins. -func Default() *Registry { - defaultRegistryOnce.Do(func() { - defaultRegistry = NewRegistry() - defaultRegistry.RegisterBuiltins() - }) - return defaultRegistry -} - -// NewRegistry creates a new empty preset registry. -func NewRegistry() *Registry { - return &Registry{ - presets: make(map[string]Preset), - } -} - -// Register registers a preset. Project presets override user presets which override builtins. -func (r *Registry) Register(p Preset) error { - if p.Name == "" { - return fmt.Errorf("preset name cannot be empty") - } - r.mu.Lock() - defer r.mu.Unlock() - - normName := strings.ToLower(strings.TrimSpace(p.Name)) - p.Name = normName - - if existing, ok := r.presets[normName]; ok { - // Respect precedence: project > user > builtin - if shouldOverride(existing.Source, p.Source) { - r.presets[normName] = p - } - return nil - } - - r.presets[normName] = p - return nil -} - -// Get looks up a preset by name. -func (r *Registry) Get(name string) (Preset, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - - normName := strings.ToLower(strings.TrimSpace(name)) - p, ok := r.presets[normName] - return p, ok -} - -// List returns all registered presets. -func (r *Registry) List() []Preset { - r.mu.RLock() - defer r.mu.RUnlock() - - list := make([]Preset, 0, len(r.presets)) - for _, p := range r.presets { - list = append(list, p) - } - return list -} - -// RegisterBuiltins registers standard core agent presets. -func (r *Registry) RegisterBuiltins() { - builtins := []Preset{ - { - Name: "code-reviewer", - Description: "Reviews code changes for security, style, and correctness", - SubagentType: "explore", - CapabilityMode: "read-only", - SandboxMode: "strict", - SystemPrompt: "You are an expert code reviewer. Analyze changes carefully for correctness, edge cases, performance, and security.", - Tools: []string{"Read", "Grep", "Glob", "LS"}, - DenyTools: []string{"Write", "Patch", "DeleteFile"}, - Source: SourceBuiltin, - }, - { - Name: "security-auditor", - Description: "Audits codebase for secrets, vulnerabilities, and risky patterns", - SubagentType: "explore", - CapabilityMode: "read-only", - SandboxMode: "strict", - SystemPrompt: "You are a cybersecurity auditor. Scan the codebase for hardcoded credentials, injection risks, and insecure configurations.", - Tools: []string{"Read", "Grep", "Glob", "LS", "CodeSearch"}, - DenyTools: []string{"Write", "Patch", "Bash"}, - Source: SourceBuiltin, - }, - { - Name: "architect", - Description: "System architecture and implementation planning specialist", - SubagentType: "plan", - CapabilityMode: "read-only", - SandboxMode: "workspace", - SystemPrompt: "You are a senior software architect. Analyze requirements, assess system structure, and produce structured implementation plans.", - Tools: []string{"Read", "Grep", "Glob", "LS", "CodeSearch"}, - DenyTools: []string{"Write", "DeleteFile"}, - Source: SourceBuiltin, - }, - { - Name: "pair-programmer", - Description: "General interactive assistant with full coding capabilities", - SubagentType: "general-purpose", - CapabilityMode: "all", - SandboxMode: "workspace", - SystemPrompt: "You are a collaborative pair programmer. Write high-quality, verified code following project conventions.", - Source: SourceBuiltin, - }, - { - Name: "debugger", - Description: "Root-cause diagnostic specialist for test and runtime failures", - SubagentType: "general-purpose", - CapabilityMode: "read-write", - SandboxMode: "workspace", - SystemPrompt: "You are a diagnostic debugging specialist. Investigate failures, inspect logs and call traces, and craft precise fixes.", - Source: SourceBuiltin, - }, - } - - for _, p := range builtins { - _ = r.Register(p) - } -} - -// LoadFromDir scans a directory for JSON/YAML preset definitions. -func (r *Registry) LoadFromDir(dir string, source Source) error { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(entry.Name())) - if ext != ".json" && ext != ".yaml" && ext != ".yml" { - continue - } - - path := filepath.Join(dir, entry.Name()) - data, err := os.ReadFile(path) - if err != nil { - continue - } - - var p Preset - if ext == ".json" { - if err := json.Unmarshal(data, &p); err != nil { - continue - } - } else { - // Minimal fallback for simple YAML - if err := parseSimpleYAML(data, &p); err != nil { - continue - } - } - - if p.Name == "" { - p.Name = strings.TrimSuffix(entry.Name(), ext) - } - p.Source = source - _ = r.Register(p) - } - - return nil -} - -func shouldOverride(current, candidate Source) bool { - weight := map[Source]int{ - SourceBuiltin: 1, - SourceUser: 2, - SourceProject: 3, - } - return weight[candidate] >= weight[current] -} - -func parseSimpleYAML(data []byte, p *Preset) error { - lines := strings.Split(string(data), "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - parts := strings.SplitN(trimmed, ":", 2) - if len(parts) != 2 { - continue - } - key := strings.ToLower(strings.TrimSpace(parts[0])) - val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) - - switch key { - case "name": - p.Name = val - case "description": - p.Description = val - case "system_prompt": - p.SystemPrompt = val - case "model": - p.Model = val - case "subagent_type": - p.SubagentType = val - case "capability_mode": - p.CapabilityMode = val - case "sandbox_mode": - p.SandboxMode = val - } - } - return nil -} diff --git a/internal/preset/preset_test.go b/internal/preset/preset_test.go deleted file mode 100644 index d8b11cc2e..000000000 --- a/internal/preset/preset_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package preset - -import ( - "os" - "path/filepath" - "testing" -) - -func TestPreset_Builtins(t *testing.T) { - reg := Default() - - p, ok := reg.Get("code-reviewer") - if !ok { - t.Fatal("expected code-reviewer builtin preset") - } - if p.SubagentType != "explore" { - t.Errorf("SubagentType = %q, want explore", p.SubagentType) - } - if p.CapabilityMode != "read-only" { - t.Errorf("CapabilityMode = %q, want read-only", p.CapabilityMode) - } - if p.SandboxMode != "strict" { - t.Errorf("SandboxMode = %q, want strict", p.SandboxMode) - } - - p2, ok := reg.Get("architect") - if !ok { - t.Fatal("expected architect builtin preset") - } - if p2.SubagentType != "plan" { - t.Errorf("SubagentType = %q, want plan", p2.SubagentType) - } - - list := reg.List() - if len(list) < 5 { - t.Errorf("expected at least 5 builtin presets, got %d", len(list)) - } -} - -func TestPreset_PrecedenceOverride(t *testing.T) { - reg := NewRegistry() - - // 1. Builtin - _ = reg.Register(Preset{ - Name: "custom-agent", - Description: "Builtin version", - Source: SourceBuiltin, - }) - - p, _ := reg.Get("custom-agent") - if p.Description != "Builtin version" { - t.Errorf("got %q, want Builtin version", p.Description) - } - - // 2. User overrides Builtin - _ = reg.Register(Preset{ - Name: "custom-agent", - Description: "User version", - Source: SourceUser, - }) - p, _ = reg.Get("custom-agent") - if p.Description != "User version" { - t.Errorf("got %q, want User version", p.Description) - } - - // 3. Project overrides User - _ = reg.Register(Preset{ - Name: "custom-agent", - Description: "Project version", - Source: SourceProject, - }) - p, _ = reg.Get("custom-agent") - if p.Description != "Project version" { - t.Errorf("got %q, want Project version", p.Description) - } - - // 4. Builtin cannot downgrade Project - _ = reg.Register(Preset{ - Name: "custom-agent", - Description: "Builtin attempt", - Source: SourceBuiltin, - }) - p, _ = reg.Get("custom-agent") - if p.Description != "Project version" { - t.Errorf("got %q, want Project version", p.Description) - } -} - -func TestPreset_LoadFromDir(t *testing.T) { - tempDir := t.TempDir() - - // Write a JSON preset - jsonContent := `{ - "name": "json-specialist", - "description": "JSON specialist description", - "subagent_type": "explore", - "capability_mode": "read-only" - }` - if err := os.WriteFile(filepath.Join(tempDir, "specialist.json"), []byte(jsonContent), 0o600); err != nil { - t.Fatal(err) - } - - // Write a YAML preset - yamlContent := `name: yaml-specialist -description: YAML specialist description -subagent_type: plan -capability_mode: read-only -` - if err := os.WriteFile(filepath.Join(tempDir, "specialist.yaml"), []byte(yamlContent), 0o600); err != nil { - t.Fatal(err) - } - - reg := NewRegistry() - if err := reg.LoadFromDir(tempDir, SourceProject); err != nil { - t.Fatalf("LoadFromDir failed: %v", err) - } - - pJSON, ok := reg.Get("json-specialist") - if !ok || pJSON.Description != "JSON specialist description" { - t.Errorf("json-specialist = %+v", pJSON) - } - - pYaml, ok := reg.Get("yaml-specialist") - if !ok || pYaml.Description != "YAML specialist description" { - t.Errorf("yaml-specialist = %+v", pYaml) - } -} diff --git a/internal/profile/profile.go b/internal/profile/profile.go deleted file mode 100644 index 75caec01e..000000000 --- a/internal/profile/profile.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package profile provides runtime profiling helpers. -package profile - -import ( - "fmt" - "os" - "runtime" - "runtime/pprof" - "time" -) - -// CPUProfile starts CPU profiling to the given file. -func CPUProfile(path string) (func(), error) { - f, err := os.Create(path) // #nosec G304 -- path is an operator-supplied profiling output destination, not external input - if err != nil { - return nil, fmt.Errorf("create profile file: %w", err) - } - if err := pprof.StartCPUProfile(f); err != nil { - _ = f.Close() - return nil, fmt.Errorf("start CPU profile: %w", err) - } - return func() { - pprof.StopCPUProfile() - _ = f.Close() - }, nil -} - -// MemoryProfile writes a heap profile to the given file. -func MemoryProfile(path string) error { - f, err := os.Create(path) // #nosec G304 -- path is an operator-supplied profiling output destination, not external input - if err != nil { - return fmt.Errorf("create profile file: %w", err) - } - defer func() { _ = f.Close() }() - - runtime.GC() - if err := pprof.WriteHeapProfile(f); err != nil { - return fmt.Errorf("write heap profile: %w", err) - } - return nil -} - -// GoroutineProfile writes a goroutine profile to the given file. -func GoroutineProfile(path string) error { - f, err := os.Create(path) // #nosec G304 -- path is an operator-supplied profiling output destination, not external input - if err != nil { - return fmt.Errorf("create profile file: %w", err) - } - defer func() { _ = f.Close() }() - - if err := pprof.Lookup("goroutine").WriteTo(f, 1); err != nil { - return fmt.Errorf("write goroutine profile: %w", err) - } - return nil -} - -// Stats returns runtime statistics. -type Stats struct { - NumGoroutine int `json:"num_goroutine"` - NumCPU int `json:"num_cpu"` - MemAlloc uint64 `json:"mem_alloc"` - MemTotalAlloc uint64 `json:"mem_total_alloc"` - MemSys uint64 `json:"mem_sys"` - MemNumGC uint32 `json:"mem_num_gc"` - Timestamp int64 `json:"timestamp"` -} - -// GetStats returns current runtime statistics. -func GetStats() Stats { - var m runtime.MemStats - runtime.ReadMemStats(&m) - return Stats{ - NumGoroutine: runtime.NumGoroutine(), - NumCPU: runtime.NumCPU(), - MemAlloc: m.Alloc, - MemTotalAlloc: m.TotalAlloc, - MemSys: m.Sys, - MemNumGC: m.NumGC, - Timestamp: time.Now().Unix(), - } -} - -// Format returns formatted runtime stats. -func Format(s Stats) string { - return fmt.Sprintf( - "goroutines: %d | CPU: %d | alloc: %.2f MB | total: %.2f MB | sys: %.2f MB | GC: %d", - s.NumGoroutine, s.NumCPU, - float64(s.MemAlloc)/(1024*1024), - float64(s.MemTotalAlloc)/(1024*1024), - float64(s.MemSys)/(1024*1024), - s.MemNumGC, - ) -} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go deleted file mode 100644 index 9662654df..000000000 --- a/internal/profile/profile_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package profile - -import ( - "os" - "strings" - "testing" -) - -func TestGetStats(t *testing.T) { - s := GetStats() - if s.NumGoroutine == 0 { - t.Fatal("expected some goroutines") - } - if s.NumCPU == 0 { - t.Fatal("expected some CPUs") - } -} - -func TestFormat(t *testing.T) { - s := Stats{ - NumGoroutine: 10, - NumCPU: 8, - MemAlloc: 1024 * 1024, - MemTotalAlloc: 2 * 1024 * 1024, - MemSys: 4 * 1024 * 1024, - MemNumGC: 5, - } - formatted := Format(s) - if !strings.Contains(formatted, "goroutines: 10") { - t.Fatalf("expected goroutines in output, got: %s", formatted) - } -} - -func TestMemoryProfile(t *testing.T) { - path := "/tmp/test_heap.prof" - defer os.Remove(path) - - err := MemoryProfile(path) - if err != nil { - t.Fatal(err) - } - - _, err = os.Stat(path) - if err != nil { - t.Fatal("profile file not created") - } -} - -func TestGoroutineProfile(t *testing.T) { - path := "/tmp/test_goroutine.prof" - defer os.Remove(path) - - err := GoroutineProfile(path) - if err != nil { - t.Fatal(err) - } - - _, err = os.Stat(path) - if err != nil { - t.Fatal("profile file not created") - } -} diff --git a/internal/resilience/circuit.go b/internal/resilience/circuit.go deleted file mode 100644 index 0e5ce2494..000000000 --- a/internal/resilience/circuit.go +++ /dev/null @@ -1,250 +0,0 @@ -// Package circuit provides a circuit breaker pattern for resilience. -// When failures exceed a threshold, the circuit opens and fails fast. -// After a timeout, the circuit transitions to half-open to test recovery. -package circuit - -import ( - "errors" - "sync" - "time" -) - -// State represents the circuit breaker state. -type State int - -const ( - Closed State = iota // Normal operation - Open // Failing fast - HalfOpen // Testing recovery -) - -func (s State) String() string { - switch s { - case Closed: - return "closed" - case Open: - return "open" - case HalfOpen: - return "half-open" - default: - return "unknown" - } -} - -// Breaker is a circuit breaker. -type Breaker struct { - mu sync.RWMutex - state State - failures int - lastFailureTime time.Time - successCount int - // halfOpenCalls counts probe calls dispatched in the half-open state so - // a burst of traffic cannot all slip through to a recovering service. - halfOpenCalls int - - maxFailures int - timeout time.Duration - halfOpenMaxCalls int -} - -// ErrOpen is returned when the circuit is open. -var ErrOpen = errors.New("circuit breaker is open") - -// Config configures a circuit breaker. -type Config struct { - MaxFailures int - Timeout time.Duration - HalfOpenMaxCalls int -} - -// DefaultConfig returns sensible defaults. -func DefaultConfig() Config { - return Config{ - MaxFailures: 5, - Timeout: 30 * time.Second, - HalfOpenMaxCalls: 3, - } -} - -// New creates a new circuit breaker. -func New(cfg Config) *Breaker { - return &Breaker{ - maxFailures: cfg.MaxFailures, - timeout: cfg.Timeout, - halfOpenMaxCalls: cfg.HalfOpenMaxCalls, - } -} - -// State returns the current state. -func (b *Breaker) State() State { - b.mu.RLock() - defer b.mu.RUnlock() - return b.state -} - -// Allow checks if a call should be allowed. -func (b *Breaker) Allow() bool { - b.mu.Lock() - defer b.mu.Unlock() - - if b.state == Open { - if time.Since(b.lastFailureTime) > b.timeout { - b.state = HalfOpen - b.successCount = 0 - b.halfOpenCalls = 1 // this first probe counts against the budget - return true - } - return false - } - - if b.state == HalfOpen { - // Bound the number of in-flight probe calls so a traffic burst does - // not all slip through to a service that is still recovering. Once the - // probe budget is spent, fail fast until RecordSuccess closes the - // circuit or RecordFailure reopens it. - if b.halfOpenCalls >= b.halfOpenMaxCalls { - return false - } - b.halfOpenCalls++ - return true - } - - return true -} - -// RecordSuccess records a successful call. -func (b *Breaker) RecordSuccess() { - b.mu.Lock() - defer b.mu.Unlock() - - if b.state == HalfOpen { - b.successCount++ - if b.successCount >= b.halfOpenMaxCalls { - b.state = Closed - b.failures = 0 - b.successCount = 0 - b.halfOpenCalls = 0 - } - return - } - - if b.state == Closed { - b.failures = 0 - } -} - -// RecordFailure records a failed call. -func (b *Breaker) RecordFailure() { - b.mu.Lock() - defer b.mu.Unlock() - - b.failures++ - b.lastFailureTime = time.Now() - - if b.state == HalfOpen { - b.state = Open - return - } - - if b.failures >= b.maxFailures { - b.state = Open - } -} - -// Call executes fn if the circuit allows it. -func (b *Breaker) Call(fn func() error) error { - if !b.Allow() { - return ErrOpen - } - - err := fn() - if err != nil { - b.RecordFailure() - return err - } - - b.RecordSuccess() - return nil -} - -// CallWithResult executes fn if the circuit allows it. -func CallWithResult[T any](b *Breaker, fn func() (T, error)) (T, error) { - var zero T - if !b.Allow() { - return zero, ErrOpen - } - - result, err := fn() - if err != nil { - b.RecordFailure() - return zero, err - } - - b.RecordSuccess() - return result, nil -} - -// Stats returns breaker statistics. -type Stats struct { - State string `json:"state"` - Failures int `json:"failures"` - LastFailure time.Time `json:"last_failure,omitempty"` -} - -// Stats returns current statistics. -func (b *Breaker) Stats() Stats { - b.mu.RLock() - defer b.mu.RUnlock() - return Stats{ - State: b.state.String(), - Failures: b.failures, - LastFailure: b.lastFailureTime, - } -} - -// Manager manages multiple named circuit breakers. -type Manager struct { - mu sync.RWMutex - breakers map[string]*Breaker - config Config -} - -// NewManager creates a new circuit breaker manager. -func NewManager(cfg Config) *Manager { - return &Manager{ - breakers: make(map[string]*Breaker), - config: cfg, - } -} - -// Get returns a breaker by name, creating it if needed. -func (m *Manager) Get(name string) *Breaker { - m.mu.RLock() - b, ok := m.breakers[name] - m.mu.RUnlock() - if ok { - return b - } - - m.mu.Lock() - defer m.mu.Unlock() - // Double-check - if existingB, ok := m.breakers[name]; ok { - return existingB - } - b = New(m.config) - m.breakers[name] = b - return b -} - -// List returns all breaker names and their stats. -func (m *Manager) List() map[string]Stats { - m.mu.RLock() - defer m.mu.RUnlock() - - out := make(map[string]Stats, len(m.breakers)) - for name, b := range m.breakers { - out[name] = b.Stats() - } - return out -} diff --git a/internal/resilience/circuit_test.go b/internal/resilience/circuit_test.go deleted file mode 100644 index 9da9f8c97..000000000 --- a/internal/resilience/circuit_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package circuit - -import ( - "errors" - "testing" - "time" -) - -func TestStateString(t *testing.T) { - tests := []struct { - state State - expected string - }{ - {Closed, "closed"}, - {Open, "open"}, - {HalfOpen, "half-open"}, - {State(99), "unknown"}, - } - - for _, tt := range tests { - if s := tt.state.String(); s != tt.expected { - t.Errorf("State(%d).String() = %q, want %q", tt.state, s, tt.expected) - } - } -} - -func TestBreakerClosed(t *testing.T) { - b := New(Config{MaxFailures: 3, Timeout: 1 * time.Second}) - - if b.State() != Closed { - t.Fatalf("expected closed state, got %s", b.State()) - } - - // Successful calls should work - for i := 0; i < 5; i++ { - err := b.Call(func() error { return nil }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - if b.State() != Closed { - t.Fatalf("expected closed after successes, got %s", b.State()) - } -} - -func TestBreakerOpens(t *testing.T) { - b := New(Config{MaxFailures: 3, Timeout: 1 * time.Hour}) - - // Fail 3 times - for i := 0; i < 3; i++ { - err := b.Call(func() error { return errors.New("fail") }) - if err == nil || errors.Is(err, ErrOpen) { - t.Fatalf("expected error, got %v", err) - } - } - - if b.State() != Open { - t.Fatalf("expected open state, got %s", b.State()) - } - - // Next call should fail fast - err := b.Call(func() error { return nil }) - if err != ErrOpen { - t.Fatalf("expected ErrOpen, got %v", err) - } -} - -func TestBreakerHalfOpen(t *testing.T) { - b := New(Config{MaxFailures: 2, Timeout: 50 * time.Millisecond, HalfOpenMaxCalls: 2}) - - // Fail to open - for i := 0; i < 2; i++ { - b.Call(func() error { return errors.New("fail") }) - } - - if b.State() != Open { - t.Fatal("expected open") - } - - // Wait for timeout - time.Sleep(100 * time.Millisecond) - - // Should be half-open - if !b.Allow() { - t.Fatal("expected allow after timeout") - } - if b.State() != HalfOpen { - t.Fatalf("expected half-open, got %s", b.State()) - } - - // Success should move back to closed - b.RecordSuccess() - b.RecordSuccess() - - if b.State() != Closed { - t.Fatalf("expected closed after successes, got %s", b.State()) - } -} - -func TestBreakerHalfOpenFailure(t *testing.T) { - b := New(Config{MaxFailures: 2, Timeout: 50 * time.Millisecond}) - - // Open the circuit - for i := 0; i < 2; i++ { - b.Call(func() error { return errors.New("fail") }) - } - - // Wait for timeout - time.Sleep(100 * time.Millisecond) - - // Should be half-open - if !b.Allow() { - t.Fatal("expected allow after timeout") - } - - // Failure in half-open should go back to open - b.RecordFailure() - - if b.State() != Open { - t.Fatalf("expected open after half-open failure, got %s", b.State()) - } -} - -func TestBreakerHalfOpenProbeBudget(t *testing.T) { - b := New(Config{MaxFailures: 1, Timeout: 50 * time.Millisecond, HalfOpenMaxCalls: 2}) - - _ = b.Call(func() error { return errors.New("fail") }) - if b.State() != Open { - t.Fatalf("expected open state, got %s", b.State()) - } - - time.Sleep(100 * time.Millisecond) - - if !b.Allow() { - t.Fatal("expected first half-open probe to be allowed") - } - if !b.Allow() { - t.Fatal("expected second half-open probe to be allowed") - } - if b.Allow() { - t.Fatal("expected probe budget to block additional half-open calls") - } -} - -func TestCallWithResult(t *testing.T) { - b := New(Config{MaxFailures: 3}) - - result, err := CallWithResult(b, func() (string, error) { - return "hello", nil - }) - if err != nil { - t.Fatal(err) - } - if result != "hello" { - t.Fatalf("expected 'hello', got %q", result) - } - - // Open circuit - b2 := New(Config{MaxFailures: 3, Timeout: 1 * time.Hour}) - for i := 0; i < 3; i++ { - CallWithResult(b2, func() (string, error) { return "", errors.New("fail") }) - } - - _, err = CallWithResult(b2, func() (string, error) { - return "hello", nil - }) - if err != ErrOpen { - t.Fatalf("expected ErrOpen, got %v", err) - } -} - -func TestStats(t *testing.T) { - b := New(Config{MaxFailures: 3}) - stats := b.Stats() - if stats.State != "closed" { - t.Fatalf("expected state 'closed', got %q", stats.State) - } - if stats.Failures != 0 { - t.Fatalf("expected 0 failures, got %d", stats.Failures) - } -} - -func TestManager(t *testing.T) { - m := NewManager(DefaultConfig()) - - b1 := m.Get("api1") - b2 := m.Get("api1") - if b1 != b2 { - t.Fatal("expected same breaker instance") - } - - b3 := m.Get("api2") - if b1 == b3 { - t.Fatal("expected different breaker instances") - } - - stats := m.List() - if len(stats) != 2 { - t.Fatalf("expected 2 breakers, got %d", len(stats)) - } -} - -func TestDefaultConfig(t *testing.T) { - cfg := DefaultConfig() - if cfg.MaxFailures != 5 { - t.Fatalf("expected max failures 5, got %d", cfg.MaxFailures) - } - if cfg.Timeout != 30*time.Second { - t.Fatalf("expected timeout 30s, got %v", cfg.Timeout) - } -} diff --git a/internal/system/bus/bus.go b/internal/system/bus/bus.go deleted file mode 100644 index 6953882a6..000000000 --- a/internal/system/bus/bus.go +++ /dev/null @@ -1,65 +0,0 @@ -package bus - -import "context" - -// Producer publishes messages to a topic/stream. -type Producer interface { - Publish(ctx context.Context, data []byte) error - Close() error -} - -// Consumer receives messages from a topic/stream. -type Consumer interface { - Listen(ctx context.Context, handler func(ctx context.Context, data []byte) error) error - Close() error -} - -// ChannelProducer is an in-process Producer backed by a Go channel. -type ChannelProducer struct { - ch chan<- []byte -} - -func NewChannelBus(bufSize int) (*ChannelProducer, *ChannelConsumer) { - if bufSize <= 0 { - bufSize = 256 - } - ch := make(chan []byte, bufSize) - return &ChannelProducer{ch: ch}, &ChannelConsumer{ch: ch} -} - -func (p *ChannelProducer) Publish(_ context.Context, data []byte) error { - cp := make([]byte, len(data)) - copy(cp, data) - p.ch <- cp - return nil -} - -func (p *ChannelProducer) Close() error { - close(p.ch) - return nil -} - -// ChannelConsumer is an in-process Consumer backed by a Go channel. -type ChannelConsumer struct { - ch <-chan []byte -} - -func (c *ChannelConsumer) Listen(ctx context.Context, handler func(ctx context.Context, data []byte) error) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case msg, ok := <-c.ch: - if !ok { - return nil - } - if err := handler(ctx, msg); err != nil { - continue - } - } - } -} - -func (c *ChannelConsumer) Close() error { - return nil -} diff --git a/internal/system/bus/bus_test.go b/internal/system/bus/bus_test.go deleted file mode 100644 index ab32c8683..000000000 --- a/internal/system/bus/bus_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package bus - -import ( - "context" - "sync/atomic" - "testing" - "time" -) - -func TestChannelBusPublishListen(t *testing.T) { - prod, cons := NewChannelBus(8) - - received := make(chan []byte, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - _ = cons.Listen(ctx, func(_ context.Context, data []byte) error { - received <- data - return nil - }) - }() - - if err := prod.Publish(ctx, []byte("hello")); err != nil { - t.Fatalf("Publish: %v", err) - } - - select { - case msg := <-received: - if string(msg) != "hello" { - t.Errorf("received %q, want %q", msg, "hello") - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for message") - } -} - -func TestChannelBusCopiesData(t *testing.T) { - prod, cons := NewChannelBus(8) - ctx := context.Background() - - received := make(chan []byte, 1) - go func() { - _ = cons.Listen(ctx, func(_ context.Context, data []byte) error { - received <- data - return nil - }) - }() - - orig := []byte("original") - if err := prod.Publish(ctx, orig); err != nil { - t.Fatalf("Publish: %v", err) - } - - // Mutate original after publish. - orig[0] = 'X' - - select { - case msg := <-received: - if msg[0] == 'X' { - t.Error("Publish should copy data, but original mutation was visible") - } - case <-time.After(time.Second): - t.Fatal("timed out") - } -} - -func TestChannelConsumerContextCancel(t *testing.T) { - _, cons := NewChannelBus(8) - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan error, 1) - go func() { - done <- cons.Listen(ctx, func(_ context.Context, data []byte) error { - return nil - }) - }() - - cancel() - - select { - case err := <-done: - if err != context.Canceled { - t.Errorf("Listen returned %v, want context.Canceled", err) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for Listen to return") - } -} - -func TestChannelProducerClose(t *testing.T) { - prod, cons := NewChannelBus(8) - ctx := context.Background() - - done := make(chan error, 1) - go func() { - done <- cons.Listen(ctx, func(_ context.Context, data []byte) error { - return nil - }) - }() - - if err := prod.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - select { - case err := <-done: - if err != nil { - t.Errorf("Listen returned %v after Close, want nil", err) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for Listen to return after Close") - } -} - -func TestChannelConsumerCloseNoop(t *testing.T) { - _, cons := NewChannelBus(8) - if err := cons.Close(); err != nil { - t.Errorf("Consumer.Close: %v", err) - } -} - -func TestChannelBusHandlerError(t *testing.T) { - prod, cons := NewChannelBus(8) - ctx := context.Background() - - var count atomic.Int32 - go func() { - _ = cons.Listen(ctx, func(_ context.Context, data []byte) error { - count.Add(1) - return context.Canceled // non-nil error - }) - }() - - prod.Publish(ctx, []byte("msg1")) - prod.Publish(ctx, []byte("msg2")) - prod.Close() - - // Both messages should be received despite handler errors. - time.Sleep(50 * time.Millisecond) - if count.Load() != 2 { - t.Errorf("handler called %d times, want 2", count.Load()) - } -} - -func TestNewChannelBusDefaultSize(t *testing.T) { - prod, cons := NewChannelBus(0) - if prod == nil || cons == nil { - t.Fatal("NewChannelBus(0) returned nil") - } - // Should still work. - ctx := context.Background() - received := make(chan []byte, 1) - go func() { - _ = cons.Listen(ctx, func(_ context.Context, data []byte) error { - received <- data - return nil - }) - }() - prod.Publish(ctx, []byte("test")) - prod.Close() - select { - case msg := <-received: - if string(msg) != "test" { - t.Errorf("received %q, want test", msg) - } - case <-time.After(time.Second): - t.Fatal("timed out") - } -} diff --git a/internal/system/cron/cron.go b/internal/system/cron/cron.go deleted file mode 100644 index 28033d96b..000000000 --- a/internal/system/cron/cron.go +++ /dev/null @@ -1,330 +0,0 @@ -package cron - -import ( - "encoding/json" - "fmt" - "math/rand/v2" - "sync" - "time" - - "github.com/GrayCodeAI/hawk/internal/eventlog" -) - -type ScheduleKind string - -const ( - ScheduleAt ScheduleKind = "at" - ScheduleEvery ScheduleKind = "every" -) - -type Schedule struct { - Kind ScheduleKind `json:"kind"` - At *time.Time `json:"at,omitempty"` - Every time.Duration `json:"every,omitempty"` -} - -type SessionTarget string - -const ( - SessionMain SessionTarget = "main" - SessionIsolated SessionTarget = "isolated" -) - -type Job struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Enabled bool `json:"enabled"` - Schedule Schedule `json:"schedule"` - SessionTarget SessionTarget `json:"session_target"` - Payload string `json:"payload"` - DeleteAfterRun bool `json:"delete_after_run,omitempty"` - ConsecutiveErrors int `json:"consecutive_errors"` - LastRunAt *time.Time `json:"last_run_at,omitempty"` - LastStatus RunStatus `json:"last_status,omitempty"` - NextRunAt *time.Time `json:"next_run_at,omitempty"` - MaxRetries int `json:"max_retries"` - CreatedAt time.Time `json:"created_at"` -} - -type RunStatus string - -const ( - StatusOK RunStatus = "ok" - StatusError RunStatus = "error" - StatusSkipped RunStatus = "skipped" -) - -type RunRecord struct { - Timestamp time.Time `json:"timestamp"` - JobID string `json:"job_id"` - Status RunStatus `json:"status"` - DurationMs int64 `json:"duration_ms"` - Error string `json:"error,omitempty"` -} - -type JobHandler func(job *Job) error - -type Engine struct { - mu sync.RWMutex - jobs map[string]*Job - handler JobHandler - running bool - stopCh chan struct{} - maxConcurrent int - inFlight int - runs []RunRecord - // inFlightWG tracks in-flight job goroutines so Stop() can drain them. - inFlightWG sync.WaitGroup - // journal is optional; when set, job lifecycle changes emit schedule.change - // events (DSH schedule.change seam). - journal *eventlog.Log -} - -// SetJournal attaches the append-only event spine for schedule.change emission. -// Nil-safe; calling with nil is a no-op. -func (e *Engine) SetJournal(j *eventlog.Log) { - if e == nil { - return - } - e.journal = j -} - -// scheduleString renders a job's schedule as a descriptive string for logging. -func scheduleString(job *Job) string { - switch job.Schedule.Kind { - case ScheduleEvery: - return "every " + job.Schedule.Every.String() - case ScheduleAt: - if job.Schedule.At != nil { - return "at " + job.Schedule.At.Format(time.RFC3339) - } - return "at " - default: - return "unknown" - } -} - -// emitScheduleChange emits a schedule.change event if a journal is attached. -func (e *Engine) emitScheduleChange(job *Job, action string) { - if e == nil || e.journal == nil || job == nil { - return - } - e.journal.AppendScheduleChange(job.Name + " (" + action + ") " + scheduleString(job)) -} - -func NewEngine(handler JobHandler, maxConcurrent int) *Engine { - if maxConcurrent <= 0 { - maxConcurrent = 3 - } - return &Engine{ - jobs: make(map[string]*Job), - handler: handler, - stopCh: make(chan struct{}), - maxConcurrent: maxConcurrent, - runs: make([]RunRecord, 0), - } -} - -func (e *Engine) AddJob(job *Job) error { - e.mu.Lock() - defer e.mu.Unlock() - - if job.ID == "" { - return fmt.Errorf("job ID required") - } - job.CreatedAt = time.Now() - e.computeNextRun(job) - e.jobs[job.ID] = job - e.emitScheduleChange(job, "added") - return nil -} - -func (e *Engine) RemoveJob(id string) { - e.mu.Lock() - defer e.mu.Unlock() - if job, ok := e.jobs[id]; ok { - e.emitScheduleChange(job, "removed") - delete(e.jobs, id) - } -} - -func (e *Engine) EnableJob(id string, enabled bool) { - e.mu.Lock() - defer e.mu.Unlock() - if job, ok := e.jobs[id]; ok { - action := "disabled" - if enabled { - action = "enabled" - } - job.Enabled = enabled - if enabled { - e.computeNextRun(job) - } - e.emitScheduleChange(job, action) - } -} - -func (e *Engine) ListJobs() []*Job { - e.mu.RLock() - defer e.mu.RUnlock() - jobs := make([]*Job, 0, len(e.jobs)) - for _, j := range e.jobs { - jobs = append(jobs, j) - } - return jobs -} - -func (e *Engine) Start() { - e.mu.Lock() - if e.running { - e.mu.Unlock() - return - } - e.running = true - e.stopCh = make(chan struct{}) - e.mu.Unlock() - - go e.loop() -} - -func (e *Engine) Stop() { - e.mu.Lock() - if e.running { - close(e.stopCh) - e.running = false - } - e.mu.Unlock() - // Wait for in-flight jobs to finish so handlers are not killed mid- - // execution (which would leave state mutations incomplete). - e.inFlightWG.Wait() -} - -func (e *Engine) Status() map[string]interface{} { - e.mu.RLock() - defer e.mu.RUnlock() - - enabled := 0 - for _, j := range e.jobs { - if j.Enabled { - enabled++ - } - } - - return map[string]interface{}{ - "running": e.running, - "total_jobs": len(e.jobs), - "enabled_jobs": enabled, - "in_flight": e.inFlight, - } -} - -func (e *Engine) loop() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-e.stopCh: - return - case now := <-ticker.C: - e.tick(now) - } - } -} - -func (e *Engine) tick(now time.Time) { - e.mu.Lock() - defer e.mu.Unlock() - - for _, job := range e.jobs { - if !job.Enabled || job.NextRunAt == nil { - continue - } - if now.Before(*job.NextRunAt) { - continue - } - if e.inFlight >= e.maxConcurrent { - break - } - if job.ConsecutiveErrors >= job.MaxRetries && job.MaxRetries > 0 { - job.Enabled = false - continue - } - - e.inFlight++ - e.inFlightWG.Add(1) - go e.executeJob(job) - } -} - -func (e *Engine) executeJob(job *Job) { - defer e.inFlightWG.Done() - start := time.Now() - err := e.handler(job) - duration := time.Since(start) - - e.mu.Lock() - defer e.mu.Unlock() - - e.inFlight-- - now := time.Now() - job.LastRunAt = &now - - record := RunRecord{ - Timestamp: now, - JobID: job.ID, - DurationMs: duration.Milliseconds(), - } - - if err != nil { - job.LastStatus = StatusError - job.ConsecutiveErrors++ - record.Status = StatusError - record.Error = err.Error() - } else { - job.LastStatus = StatusOK - job.ConsecutiveErrors = 0 - record.Status = StatusOK - } - - e.runs = append(e.runs, record) - if len(e.runs) > 1000 { - e.runs = e.runs[len(e.runs)-500:] - } - - if job.DeleteAfterRun { - delete(e.jobs, job.ID) - } else { - e.computeNextRun(job) - } -} - -func (e *Engine) computeNextRun(job *Job) { - now := time.Now() - switch job.Schedule.Kind { - case ScheduleAt: - if job.Schedule.At != nil && job.Schedule.At.After(now) { - job.NextRunAt = job.Schedule.At - } else { - job.NextRunAt = nil - job.Enabled = false - } - case ScheduleEvery: - jitter := time.Duration(rand.IntN(5)) * time.Second // #nosec G404 -- non-cryptographic use (jitter for cron schedule spacing) - next := now.Add(job.Schedule.Every + jitter) - job.NextRunAt = &next - } -} - -func (e *Engine) MarshalJSON() ([]byte, error) { - e.mu.RLock() - jobs := make([]*Job, 0, len(e.jobs)) - for _, j := range e.jobs { - jobs = append(jobs, j) - } - e.mu.RUnlock() - return json.Marshal(struct { - Jobs []*Job `json:"jobs"` - }{Jobs: jobs}) -} diff --git a/internal/system/cron/cron_test.go b/internal/system/cron/cron_test.go deleted file mode 100644 index 93fc11fba..000000000 --- a/internal/system/cron/cron_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package cron - -import ( - "strings" - "sync" - "testing" - "time" - - "github.com/GrayCodeAI/hawk/internal/eventlog" -) - -func TestNewEngineDefaults(t *testing.T) { - e := NewEngine(nil, 0) - if e.maxConcurrent != 3 { - t.Errorf("maxConcurrent = %d, want 3 (default)", e.maxConcurrent) - } -} - -func TestAddJobRequiresID(t *testing.T) { - e := NewEngine(nil, 3) - if err := e.AddJob(&Job{}); err == nil { - t.Error("AddJob with empty ID should return error") - } -} - -func TestAddAndListJobs(t *testing.T) { - e := NewEngine(nil, 3) - e.AddJob(&Job{ID: "j1", Name: "Job 1", Enabled: true}) - e.AddJob(&Job{ID: "j2", Name: "Job 2", Enabled: false}) - - jobs := e.ListJobs() - if len(jobs) != 2 { - t.Fatalf("ListJobs returned %d, want 2", len(jobs)) - } -} - -func TestRemoveJob(t *testing.T) { - e := NewEngine(nil, 3) - e.AddJob(&Job{ID: "j1", Name: "Job 1"}) - e.RemoveJob("j1") - - if len(e.ListJobs()) != 0 { - t.Error("expected 0 jobs after remove") - } -} - -func TestEnableJob(t *testing.T) { - e := NewEngine(nil, 3) - e.AddJob(&Job{ - ID: "j1", - Enabled: false, - Schedule: Schedule{Kind: ScheduleEvery, Every: time.Hour}, - }) - - e.EnableJob("j1", true) - jobs := e.ListJobs() - if len(jobs) != 1 || !jobs[0].Enabled { - t.Error("job should be enabled after EnableJob") - } - - e.EnableJob("j1", false) - jobs = e.ListJobs() - if len(jobs) != 1 || jobs[0].Enabled { - t.Error("job should be disabled after EnableJob(false)") - } -} - -func TestStatus(t *testing.T) { - e := NewEngine(nil, 3) - e.AddJob(&Job{ID: "j1", Enabled: true}) - e.AddJob(&Job{ID: "j2", Enabled: false}) - - s := e.Status() - if s["total_jobs"] != 2 { - t.Errorf("total_jobs = %v, want 2", s["total_jobs"]) - } - if s["enabled_jobs"] != 1 { - t.Errorf("enabled_jobs = %v, want 1", s["enabled_jobs"]) - } - if s["running"] != false { - t.Errorf("running = %v, want false", s["running"]) - } -} - -func TestStartStop(t *testing.T) { - e := NewEngine(func(job *Job) error { return nil }, 3) - e.AddJob(&Job{ - ID: "j1", - Enabled: true, - Schedule: Schedule{Kind: ScheduleEvery, Every: time.Hour}, - }) - - e.Start() - s := e.Status() - if s["running"] != true { - t.Error("engine should be running after Start") - } - - // Starting again should be a no-op. - e.Start() - - e.Stop() - s = e.Status() - if s["running"] != false { - t.Error("engine should not be running after Stop") - } -} - -func TestExecuteAtSchedule(t *testing.T) { - var mu sync.Mutex - var executed []string - - handler := func(job *Job) error { - mu.Lock() - executed = append(executed, job.ID) - mu.Unlock() - return nil - } - - e := NewEngine(handler, 3) - - // Add a job with an "at" schedule set far in the future so it stays enabled. - future := time.Now().Add(time.Hour) - e.AddJob(&Job{ - ID: "j1", - Enabled: true, - Schedule: Schedule{Kind: ScheduleAt, At: &future}, - }) - - // Manually set NextRunAt to now so it triggers on the next tick. - e.mu.Lock() - j := e.jobs["j1"] - now := time.Now() - j.NextRunAt = &now - e.mu.Unlock() - - e.Start() - time.Sleep(2 * time.Second) - e.Stop() - - mu.Lock() - defer mu.Unlock() - if len(executed) != 1 || executed[0] != "j1" { - t.Errorf("executed = %v, want [j1]", executed) - } -} - -func TestDeleteAfterRun(t *testing.T) { - handler := func(job *Job) error { return nil } - - e := NewEngine(handler, 3) - - future := time.Now().Add(time.Hour) - e.AddJob(&Job{ - ID: "j1", - Enabled: true, - DeleteAfterRun: true, - Schedule: Schedule{Kind: ScheduleAt, At: &future}, - }) - - // Manually set NextRunAt to now so it triggers on the next tick. - e.mu.Lock() - j := e.jobs["j1"] - now := time.Now() - j.NextRunAt = &now - e.mu.Unlock() - - e.Start() - time.Sleep(2 * time.Second) - e.Stop() - - if len(e.ListJobs()) != 0 { - t.Errorf("expected 0 jobs after delete-after-run, got %d", len(e.ListJobs())) - } -} - -func TestConsecutiveErrorsDisable(t *testing.T) { - handler := func(job *Job) error { return nil } - e := NewEngine(handler, 3) - - future := time.Now().Add(time.Hour) - e.AddJob(&Job{ - ID: "j1", - Enabled: true, - ConsecutiveErrors: 5, - MaxRetries: 3, - Schedule: Schedule{Kind: ScheduleAt, At: &future}, - }) - - // Manually set NextRunAt to now so it triggers on the next tick. - e.mu.Lock() - j := e.jobs["j1"] - now := time.Now() - j.NextRunAt = &now - e.mu.Unlock() - - e.Start() - time.Sleep(2 * time.Second) - e.Stop() - - jobs := e.ListJobs() - if len(jobs) != 1 || jobs[0].Enabled { - t.Error("job with too many errors should be disabled") - } -} - -func TestMarshalJSON(t *testing.T) { - e := NewEngine(nil, 3) - e.AddJob(&Job{ID: "j1", Name: "Job 1"}) - - data, err := e.MarshalJSON() - if err != nil { - t.Fatalf("MarshalJSON: %v", err) - } - if len(data) == 0 { - t.Error("MarshalJSON returned empty data") - } -} - -// TestScheduleChangeLog verifies that AddJob/RemoveJob/EnableJob emit -// schedule.change events through an attached eventlog.Log. -func TestScheduleChangeLog(t *testing.T) { - e := NewEngine(nil, 3) - - var observed []eventlog.Event - journal := eventlog.New(func(ev eventlog.Event) { - observed = append(observed, ev) - }) - e.SetJournal(journal) - - // AddJob should emit schedule.change. - e.AddJob(&Job{ - ID: "j1", - Name: "TestJob", - Enabled: true, - Schedule: Schedule{Kind: ScheduleEvery, Every: time.Hour}, - }) - if len(observed) != 1 { - t.Fatalf("expected 1 schedule.change after AddJob, got %d", len(observed)) - } - if observed[0].Type != eventlog.ScheduleChange { - t.Fatalf("expected ScheduleChange type, got %s", observed[0].Type) - } - f, ok := observed[0].Data.(eventlog.ScheduleChangeFact) - if !ok { - t.Fatalf("expected ScheduleChangeFact, got %T", observed[0].Data) - } - if f.Cron == "" { - t.Fatal("expected non-empty cron string") - } - if !strings.Contains(f.Cron, "added") { - t.Fatalf("expected cron to contain 'added', got %q", f.Cron) - } - - // EnableJob(false) should emit schedule.change. - e.EnableJob("j1", false) - if len(observed) != 2 { - t.Fatalf("expected 2 schedule.change events, got %d", len(observed)) - } - f2, _ := observed[1].Data.(eventlog.ScheduleChangeFact) - if !strings.Contains(f2.Cron, "disabled") { - t.Fatalf("expected cron to contain 'disabled', got %q", f2.Cron) - } - - // RemoveJob should emit schedule.change. - e.RemoveJob("j1") - if len(observed) != 3 { - t.Fatalf("expected 3 schedule.change events, got %d", len(observed)) - } - f3, _ := observed[2].Data.(eventlog.ScheduleChangeFact) - if !strings.Contains(f3.Cron, "removed") { - t.Fatalf("expected cron to contain 'removed', got %q", f3.Cron) - } -} - -// TestScheduleChangeNoJournal verifies no panic when journal is nil. -func TestScheduleChangeNoJournal(t *testing.T) { - e := NewEngine(nil, 3) - // Should not panic without a journal. - e.AddJob(&Job{ID: "j1", Name: "NoJournal", Enabled: true}) - e.EnableJob("j1", false) - e.RemoveJob("j1") -} diff --git a/internal/system/retention/retention.go b/internal/system/retention/retention.go deleted file mode 100644 index e3dead957..000000000 --- a/internal/system/retention/retention.go +++ /dev/null @@ -1,124 +0,0 @@ -package retention - -import ( - "os" - "path/filepath" - "sort" - "time" -) - -type Policy struct { - MaxAge time.Duration `json:"max_age"` - MaxSizeMB int64 `json:"max_size_mb"` -} - -func DefaultPolicy() Policy { - return Policy{ - MaxAge: 30 * 24 * time.Hour, - MaxSizeMB: 500, - } -} - -type CleanupResult struct { - FilesRemoved int - BytesFreed int64 - Errors []error -} - -// CleanDirectory removes files older than policy.MaxAge from dir. -func CleanDirectory(dir string, policy Policy) CleanupResult { - result := CleanupResult{} - cutoff := time.Now().Add(-policy.MaxAge) - - entries, err := os.ReadDir(dir) - if err != nil { - result.Errors = append(result.Errors, err) - return result - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - info, err := entry.Info() - if err != nil { - result.Errors = append(result.Errors, err) - continue - } - if info.ModTime().Before(cutoff) { - path := filepath.Join(dir, entry.Name()) - if err := os.Remove(path); err != nil { - result.Errors = append(result.Errors, err) - } else { - result.FilesRemoved++ - result.BytesFreed += info.Size() - } - } - } - return result -} - -// EnforceSize removes oldest files until total size is under policy.MaxSizeMB. -func EnforceSize(dir string, policy Policy) CleanupResult { - result := CleanupResult{} - if policy.MaxSizeMB <= 0 { - return result - } - - maxBytes := policy.MaxSizeMB * 1024 * 1024 - - entries, err := os.ReadDir(dir) - if err != nil { - result.Errors = append(result.Errors, err) - return result - } - - type fileEntry struct { - name string - size int64 - modTime time.Time - } - var files []fileEntry - var totalSize int64 - - for _, entry := range entries { - if entry.IsDir() { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - files = append(files, fileEntry{name: entry.Name(), size: info.Size(), modTime: info.ModTime()}) - totalSize += info.Size() - } - - if totalSize <= maxBytes { - return result - } - - // Sort oldest first. When mtimes are equal, keep a deterministic tie-breaker - // so cleanup does not arbitrarily delete a newer-looking file on coarse-grained - // filesystems. - sort.SliceStable(files, func(i, j int) bool { - if files[i].modTime.Equal(files[j].modTime) { - return files[i].name < files[j].name - } - return files[i].modTime.Before(files[j].modTime) - }) - - for _, f := range files { - if totalSize <= maxBytes { - break - } - path := filepath.Join(dir, f.name) - if err := os.Remove(path); err != nil { - result.Errors = append(result.Errors, err) - continue - } - result.FilesRemoved++ - result.BytesFreed += f.size - totalSize -= f.size - } - return result -} diff --git a/internal/system/retention/retention_test.go b/internal/system/retention/retention_test.go deleted file mode 100644 index 73d7c9d94..000000000 --- a/internal/system/retention/retention_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package retention - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestDefaultPolicy(t *testing.T) { - p := DefaultPolicy() - if p.MaxAge != 30*24*time.Hour { - t.Errorf("MaxAge = %v, want 30d", p.MaxAge) - } - if p.MaxSizeMB != 500 { - t.Errorf("MaxSizeMB = %d, want 500", p.MaxSizeMB) - } -} - -func TestCleanDirectoryRemovesOldFiles(t *testing.T) { - dir := t.TempDir() - - // Create a file with an old mtime. - oldFile := filepath.Join(dir, "old.txt") - if err := os.WriteFile(oldFile, []byte("old"), 0o644); err != nil { - t.Fatal(err) - } - oldTime := time.Now().Add(-48 * time.Hour) - os.Chtimes(oldFile, oldTime, oldTime) - - // Create a recent file. - recentFile := filepath.Join(dir, "recent.txt") - if err := os.WriteFile(recentFile, []byte("recent"), 0o644); err != nil { - t.Fatal(err) - } - - policy := Policy{MaxAge: 24 * time.Hour} - result := CleanDirectory(dir, policy) - - if result.FilesRemoved != 1 { - t.Errorf("FilesRemoved = %d, want 1", result.FilesRemoved) - } - if result.BytesFreed != 3 { - t.Errorf("BytesFreed = %d, want 3", result.BytesFreed) - } - if len(result.Errors) != 0 { - t.Errorf("Errors = %v, want none", result.Errors) - } - - // Recent file should still exist. - if _, err := os.Stat(recentFile); err != nil { - t.Error("recent file should still exist") - } -} - -func TestCleanDirectorySkipsDirs(t *testing.T) { - dir := t.TempDir() - oldDir := filepath.Join(dir, "olddir") - os.Mkdir(oldDir, 0o755) - oldTime := time.Now().Add(-48 * time.Hour) - os.Chtimes(oldDir, oldTime, oldTime) - - result := CleanDirectory(dir, Policy{MaxAge: time.Hour}) - if result.FilesRemoved != 0 { - t.Errorf("should not remove directories, got %d removed", result.FilesRemoved) - } -} - -func TestCleanDirectoryNonexistentDir(t *testing.T) { - result := CleanDirectory("/nonexistent/path", Policy{MaxAge: time.Hour}) - if len(result.Errors) == 0 { - t.Error("expected error for nonexistent directory") - } -} - -func TestEnforceSizeNoop(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o644) - - result := EnforceSize(dir, Policy{MaxSizeMB: 100}) - if result.FilesRemoved != 0 { - t.Errorf("FilesRemoved = %d, want 0 (under limit)", result.FilesRemoved) - } -} - -func TestEnforceSizeRemovesOldest(t *testing.T) { - oldTime := time.Now().Add(-time.Hour) - sameTime := time.Now() - - // Case 1: Both files fit under limit — nothing removed. - dir1 := t.TempDir() - os.WriteFile(filepath.Join(dir1, "a.dat"), make([]byte, 400_000), 0o644) - os.WriteFile(filepath.Join(dir1, "b.dat"), make([]byte, 400_000), 0o644) - - result := EnforceSize(dir1, Policy{MaxSizeMB: 1}) - if result.FilesRemoved != 0 { - t.Errorf("case 1: FilesRemoved = %d, want 0", result.FilesRemoved) - } - - // Case 2: Three files totaling 1.6MB, limit 1MB. Oldest removed first. - dir2 := t.TempDir() - oldFile := filepath.Join(dir2, "old.dat") - os.WriteFile(oldFile, make([]byte, 400_000), 0o644) - os.Chtimes(oldFile, oldTime, oldTime) - - midFile := filepath.Join(dir2, "mid.dat") - os.WriteFile(midFile, make([]byte, 400_000), 0o644) - os.Chtimes(midFile, sameTime, sameTime) - - newFile := filepath.Join(dir2, "new.dat") - os.WriteFile(newFile, make([]byte, 800_000), 0o644) - os.Chtimes(newFile, sameTime, sameTime) - - result = EnforceSize(dir2, Policy{MaxSizeMB: 1}) - // After removing old (400KB): 1.2MB left, still over. - // After removing mid (400KB): 800KB left, under. So 2 removed. - if result.FilesRemoved != 2 { - t.Errorf("case 2: FilesRemoved = %d, want 2", result.FilesRemoved) - } - if _, err := os.Stat(newFile); err != nil { - t.Error("case 2: newest (biggest) file should still exist") - } -} - -func TestEnforceSizeNonexistentDir(t *testing.T) { - result := EnforceSize("/nonexistent/path", Policy{MaxSizeMB: 1}) - if len(result.Errors) == 0 { - t.Error("expected error for nonexistent directory") - } -} diff --git a/internal/system/shutdown/shutdown.go b/internal/system/shutdown/shutdown.go deleted file mode 100644 index 45bddc06f..000000000 --- a/internal/system/shutdown/shutdown.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package shutdown provides graceful shutdown handling for applications. -package shutdown - -import ( - "context" - "fmt" - "os" - "os/signal" - "sync" - "syscall" - "time" -) - -// Handler manages graceful shutdown. -type Handler struct { - mu sync.Mutex - hooks []func(ctx context.Context) error - timeout time.Duration - signals []os.Signal - cancel context.CancelFunc -} - -// Config configures shutdown behavior. -type Config struct { - Timeout time.Duration - Signals []os.Signal -} - -// DefaultConfig returns sensible defaults. -func DefaultConfig() Config { - return Config{ - Timeout: 30 * time.Second, - Signals: []os.Signal{syscall.SIGINT, syscall.SIGTERM}, - } -} - -// New creates a new shutdown handler. -func New(cfg Config) *Handler { - if cfg.Timeout == 0 { - cfg.Timeout = 30 * time.Second - } - if len(cfg.Signals) == 0 { - cfg.Signals = []os.Signal{syscall.SIGINT, syscall.SIGTERM} - } - return &Handler{ - timeout: cfg.Timeout, - signals: cfg.Signals, - } -} - -// Register adds a shutdown hook. -func (h *Handler) Register(fn func(ctx context.Context) error) { - h.mu.Lock() - defer h.mu.Unlock() - h.hooks = append(h.hooks, fn) -} - -// Listen starts listening for shutdown signals. -// Returns a context that will be cancelled on shutdown. -func (h *Handler) Listen() context.Context { - ctx, cancel := context.WithCancel(context.Background()) - h.cancel = cancel - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, h.signals...) - - go func() { - sig := <-sigCh - _, _ = fmt.Fprintf(os.Stderr, "\nReceived signal: %v, shutting down gracefully...\n", sig) - h.shutdown() - }() - - return ctx -} - -// shutdown executes all hooks with timeout. -func (h *Handler) shutdown() { - if h.cancel != nil { - h.cancel() - } - - ctx, cancel := context.WithTimeout(context.Background(), h.timeout) - defer cancel() - - var wg sync.WaitGroup - for _, hook := range h.hooks { - wg.Add(1) - go func(fn func(ctx context.Context) error) { - defer wg.Done() - if err := fn(ctx); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Shutdown hook error: %v\n", err) - } - }(hook) - } - - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - - select { - case <-done: - fmt.Fprintln(os.Stderr, "Shutdown complete.") - case <-ctx.Done(): - fmt.Fprintln(os.Stderr, "Shutdown timeout exceeded, forcing exit.") - } -} - -// Trigger manually triggers shutdown. -func (h *Handler) Trigger() { - h.shutdown() -} - -// Wait blocks until shutdown is complete. -func (h *Handler) Wait() { - // No-op - shutdown is synchronous -} diff --git a/internal/system/shutdown/shutdown_test.go b/internal/system/shutdown/shutdown_test.go deleted file mode 100644 index 9e665ef06..000000000 --- a/internal/system/shutdown/shutdown_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package shutdown - -import ( - "context" - "os" - "sync/atomic" - "syscall" - "testing" - "time" -) - -func TestDefaultConfig(t *testing.T) { - cfg := DefaultConfig() - if cfg.Timeout != 30*time.Second { - t.Fatalf("expected 30s timeout, got %v", cfg.Timeout) - } - if len(cfg.Signals) != 2 { - t.Fatalf("expected 2 signals, got %d", len(cfg.Signals)) - } -} - -func TestNew(t *testing.T) { - h := New(Config{Timeout: 5 * time.Second}) - if h.timeout != 5*time.Second { - t.Fatalf("expected 5s timeout, got %v", h.timeout) - } -} - -func TestRegisterAndShutdown(t *testing.T) { - h := New(Config{Timeout: 5 * time.Second}) - - var called int32 - h.Register(func(ctx context.Context) error { - atomic.AddInt32(&called, 1) - return nil - }) - - h.Register(func(ctx context.Context) error { - atomic.AddInt32(&called, 1) - return nil - }) - - h.Trigger() - - if atomic.LoadInt32(&called) != 2 { - t.Fatalf("expected 2 hooks called, got %d", called) - } -} - -func TestShutdownWithTimeout(t *testing.T) { - h := New(Config{Timeout: 100 * time.Millisecond}) - - h.Register(func(ctx context.Context) error { - select { - case <-time.After(1 * time.Second): - return nil - case <-ctx.Done(): - return ctx.Err() - } - }) - - start := time.Now() - h.Trigger() - elapsed := time.Since(start) - - if elapsed > 500*time.Millisecond { - t.Fatalf("expected timeout, but took %v", elapsed) - } -} - -func TestListen(t *testing.T) { - h := New(Config{Timeout: 5 * time.Second, Signals: []os.Signal{syscall.SIGUSR1}}) - - var called int32 - h.Register(func(ctx context.Context) error { - atomic.AddInt32(&called, 1) - return nil - }) - - ctx := h.Listen() - - // Context should not be done yet - select { - case <-ctx.Done(): - t.Fatal("context should not be done yet") - default: - } - - // Trigger shutdown - h.Trigger() - - // Context should be done now - select { - case <-ctx.Done(): - // Expected - case <-time.After(1 * time.Second): - t.Fatal("context should be done after trigger") - } - - if atomic.LoadInt32(&called) != 1 { - t.Fatalf("expected hook called, got %d", called) - } -} - -func TestMultipleHooks(t *testing.T) { - h := New(Config{Timeout: 5 * time.Second}) - - var count int32 - for i := 0; i < 5; i++ { - h.Register(func(ctx context.Context) error { - atomic.AddInt32(&count, 1) - return nil - }) - } - - h.Trigger() - - if atomic.LoadInt32(&count) != 5 { - t.Fatalf("expected 5 hooks called, got %d", count) - } -} diff --git a/internal/system/trie.go b/internal/system/trie.go deleted file mode 100644 index 40bca210f..000000000 --- a/internal/system/trie.go +++ /dev/null @@ -1,126 +0,0 @@ -package pathtrie - -import "strings" - -type node struct { - name string - depth int - children map[string]*node - count uint64 -} - -func (n *node) addChild(name string) *node { - if child, exists := n.children[name]; exists { - return child - } - child := &node{name: name, depth: n.depth + 1, children: make(map[string]*node)} - n.children[name] = child - return child -} - -func (n *node) mergeSubtree(other *node) { - n.count += other.count - for _, otherChild := range other.children { - if target, exists := n.children[otherChild.name]; exists { - target.mergeSubtree(otherChild) - } else { - n.children[otherChild.name] = otherChild - } - } -} - -func (n *node) collapseToWildcard() { - if _, ok := n.children["*"]; ok && len(n.children) == 1 { - return - } - star := &node{name: "*", depth: n.depth + 1, children: make(map[string]*node)} - for _, child := range n.children { - star.mergeSubtree(child) - } - n.children = map[string]*node{"*": star} -} - -func (n *node) collect(prefix []string) []Pattern { - var results []Pattern - path := append(prefix, n.name) - - if n.count > 0 { - p := make([]string, len(path)) - copy(p, path) - results = append(results, Pattern{Parts: p, Count: n.count}) - } - - for _, child := range n.children { - results = append(results, child.collect(path)...) - } - return results -} - -// Pattern is a generalized path pattern with its occurrence count. -type Pattern struct { - Parts []string - Count uint64 -} - -func (p Pattern) String() string { - return strings.Join(p.Parts, "/") -} - -// Trie auto-collapses high-cardinality path segments into wildcards. -type Trie struct { - root *node - threshold int -} - -// New creates a Trie with the given high-cardinality threshold. -// When a node has more children than threshold, they collapse into "*". -func New(highCardinalityThreshold int) *Trie { - if highCardinalityThreshold <= 0 { - highCardinalityThreshold = 10 - } - return &Trie{ - root: &node{name: "", depth: 0, children: make(map[string]*node)}, - threshold: highCardinalityThreshold, - } -} - -// Insert adds a path (split by separator) into the trie. -func (t *Trie) Insert(path string, sep string) { - if sep == "" { - sep = "/" - } - parts := strings.Split(strings.Trim(path, sep), sep) - t.InsertParts(parts, 1) -} - -// InsertParts inserts pre-split path parts with a count. -func (t *Trie) InsertParts(parts []string, count uint64) { - current := t.root - for _, segment := range parts { - if wildcard, ok := current.children["*"]; ok { - current = wildcard - continue - } - if segment == "*" { - current.collapseToWildcard() - current = current.children["*"] - continue - } - if current.depth > 0 && len(current.children) >= t.threshold { - current.collapseToWildcard() - current = current.children["*"] - continue - } - current = current.addChild(segment) - } - current.count += count -} - -// Patterns returns all accumulated patterns. -func (t *Trie) Patterns() []Pattern { - var results []Pattern - for _, child := range t.root.children { - results = append(results, child.collect(nil)...) - } - return results -} diff --git a/internal/task/task.go b/internal/task/task.go deleted file mode 100644 index 06fde36f4..000000000 --- a/internal/task/task.go +++ /dev/null @@ -1,200 +0,0 @@ -package task - -import ( - "fmt" - "sync" - "time" -) - -type TaskState string - -const ( - StatePending TaskState = "pending" - StateActive TaskState = "active" - StateWaiting TaskState = "waiting" - StateCompleted TaskState = "completed" - StateFailed TaskState = "failed" - StateAbandoned TaskState = "abandoned" -) - -type PlanStep struct { - Name string `json:"name"` - Description string `json:"description"` - State TaskState `json:"state"` - Outcome string `json:"outcome,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` -} - -type Handoff struct { - ID int `json:"id"` - FromSession string `json:"from_session"` - ToSession string `json:"to_session,omitempty"` - Summary string `json:"summary"` - Context string `json:"context"` - CreatedAt time.Time `json:"created_at"` -} - -type Task struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - State TaskState `json:"state"` - Plan []PlanStep `json:"plan"` - Handoffs []Handoff `json:"handoffs"` - CreatedAt time.Time `json:"created_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - WakeupCount int `json:"wakeup_count"` - MaxWakeups int `json:"max_wakeups"` - JudgeVerified bool `json:"judge_verified"` - JudgeResult string `json:"judge_result,omitempty"` - CheckpointRef string `json:"checkpoint_ref,omitempty"` -} - -type JudgeFunc func(task *Task) (passed bool, reason string) - -type Store struct { - mu sync.RWMutex - tasks map[string]*Task - judge JudgeFunc -} - -func NewStore(judge JudgeFunc) *Store { - return &Store{ - tasks: make(map[string]*Task), - judge: judge, - } -} - -func (s *Store) Create(id, name, description string, plan []PlanStep, maxWakeups int) (*Task, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.tasks[id]; exists { - return nil, fmt.Errorf("task %s already exists", id) - } - - if maxWakeups <= 0 { - maxWakeups = 50 - } - - task := &Task{ - ID: id, - Name: name, - Description: description, - State: StatePending, - Plan: plan, - Handoffs: make([]Handoff, 0), - CreatedAt: time.Now(), - MaxWakeups: maxWakeups, - } - s.tasks[id] = task - return task, nil -} - -func (s *Store) Get(id string) *Task { - s.mu.RLock() - defer s.mu.RUnlock() - return s.tasks[id] -} - -func (s *Store) Activate(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - task, ok := s.tasks[id] - if !ok { - return fmt.Errorf("task %s not found", id) - } - task.State = StateActive - task.WakeupCount++ - - if task.WakeupCount > task.MaxWakeups { - task.State = StateFailed - return fmt.Errorf("task %s exceeded max wakeups (%d)", id, task.MaxWakeups) - } - return nil -} - -func (s *Store) AddHandoff(taskID string, handoff Handoff) error { - s.mu.Lock() - defer s.mu.Unlock() - - task, ok := s.tasks[taskID] - if !ok { - return fmt.Errorf("task %s not found", taskID) - } - handoff.ID = len(task.Handoffs) + 1 - handoff.CreatedAt = time.Now() - task.Handoffs = append(task.Handoffs, handoff) - task.State = StateWaiting - return nil -} - -func (s *Store) CompleteStep(taskID string, stepIdx int, outcome string) error { - s.mu.Lock() - defer s.mu.Unlock() - - task, ok := s.tasks[taskID] - if !ok { - return fmt.Errorf("task %s not found", taskID) - } - if stepIdx < 0 || stepIdx >= len(task.Plan) { - return fmt.Errorf("invalid step index %d", stepIdx) - } - - now := time.Now() - task.Plan[stepIdx].State = StateCompleted - task.Plan[stepIdx].Outcome = outcome - task.Plan[stepIdx].CompletedAt = &now - return nil -} - -func (s *Store) Complete(taskID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - task, ok := s.tasks[taskID] - if !ok { - return fmt.Errorf("task %s not found", taskID) - } - - // Run judge if configured - if s.judge != nil { - passed, reason := s.judge(task) - task.JudgeVerified = true - task.JudgeResult = reason - if !passed { - task.State = StateFailed - return fmt.Errorf("judge rejected: %s", reason) - } - } - - now := time.Now() - task.State = StateCompleted - task.CompletedAt = &now - return nil -} - -func (s *Store) List() []*Task { - s.mu.RLock() - defer s.mu.RUnlock() - - tasks := make([]*Task, 0, len(s.tasks)) - for _, t := range s.tasks { - tasks = append(tasks, t) - } - return tasks -} - -func (s *Store) ListActive() []*Task { - s.mu.RLock() - defer s.mu.RUnlock() - - var active []*Task - for _, t := range s.tasks { - if t.State == StateActive || t.State == StateWaiting { - active = append(active, t) - } - } - return active -} diff --git a/internal/task/task_test.go b/internal/task/task_test.go deleted file mode 100644 index 1157360d1..000000000 --- a/internal/task/task_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package task - -import ( - "testing" - "time" -) - -func TestCreateAndGet(t *testing.T) { - s := NewStore(nil) - task, err := s.Create("t1", "Test Task", "desc", nil, 10) - if err != nil { - t.Fatalf("Create: %v", err) - } - if task.ID != "t1" || task.Name != "Test Task" { - t.Errorf("task = %+v", task) - } - if task.State != StatePending { - t.Errorf("State = %q, want pending", task.State) - } - if task.MaxWakeups != 10 { - t.Errorf("MaxWakeups = %d, want 10", task.MaxWakeups) - } - - got := s.Get("t1") - if got != task { - t.Error("Get should return the same task pointer") - } -} - -func TestCreateDefaultMaxWakeups(t *testing.T) { - s := NewStore(nil) - task, _ := s.Create("t1", "T", "d", nil, 0) - if task.MaxWakeups != 50 { - t.Errorf("MaxWakeups = %d, want 50 (default)", task.MaxWakeups) - } -} - -func TestCreateDuplicate(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 10) - _, err := s.Create("t1", "T2", "d2", nil, 10) - if err == nil { - t.Error("creating duplicate task should return error") - } -} - -func TestGetNotFound(t *testing.T) { - s := NewStore(nil) - if s.Get("missing") != nil { - t.Error("Get for missing task should return nil") - } -} - -func TestActivate(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 10) - - if err := s.Activate("t1"); err != nil { - t.Fatalf("Activate: %v", err) - } - task := s.Get("t1") - if task.State != StateActive { - t.Errorf("State = %q, want active", task.State) - } - if task.WakeupCount != 1 { - t.Errorf("WakeupCount = %d, want 1", task.WakeupCount) - } -} - -func TestActivateNotFound(t *testing.T) { - s := NewStore(nil) - if err := s.Activate("missing"); err == nil { - t.Error("Activate on missing task should return error") - } -} - -func TestActivateExceedsMaxWakeups(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 2) - - s.Activate("t1") - s.Activate("t1") - err := s.Activate("t1") // 3rd wakeup exceeds max of 2 - if err == nil { - t.Error("should error when exceeding max wakeups") - } - task := s.Get("t1") - if task.State != StateFailed { - t.Errorf("State = %q, want failed after exceeding wakeups", task.State) - } -} - -func TestAddHandoff(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 10) - - err := s.AddHandoff("t1", Handoff{ - FromSession: "sess-1", - ToSession: "sess-2", - Summary: "handing off", - Context: "some context", - }) - if err != nil { - t.Fatalf("AddHandoff: %v", err) - } - - task := s.Get("t1") - if len(task.Handoffs) != 1 { - t.Fatalf("Handoffs len = %d, want 1", len(task.Handoffs)) - } - if task.Handoffs[0].ID != 1 { - t.Errorf("Handoff ID = %d, want 1", task.Handoffs[0].ID) - } - if task.State != StateWaiting { - t.Errorf("State = %q, want waiting after handoff", task.State) - } - if task.Handoffs[0].CreatedAt.IsZero() { - t.Error("Handoff CreatedAt should be set") - } -} - -func TestAddHandoffNotFound(t *testing.T) { - s := NewStore(nil) - err := s.AddHandoff("missing", Handoff{}) - if err == nil { - t.Error("AddHandoff on missing task should return error") - } -} - -func TestCompleteStep(t *testing.T) { - s := NewStore(nil) - plan := []PlanStep{ - {Name: "step1", Description: "first"}, - {Name: "step2", Description: "second"}, - } - s.Create("t1", "T", "d", plan, 10) - - if err := s.CompleteStep("t1", 0, "done"); err != nil { - t.Fatalf("CompleteStep: %v", err) - } - task := s.Get("t1") - if task.Plan[0].State != StateCompleted { - t.Errorf("step 0 state = %q, want completed", task.Plan[0].State) - } - if task.Plan[0].Outcome != "done" { - t.Errorf("step 0 outcome = %q, want done", task.Plan[0].Outcome) - } - if task.Plan[0].CompletedAt == nil { - t.Error("step 0 CompletedAt should be set") - } -} - -func TestCompleteStepInvalidIndex(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", []PlanStep{{Name: "s1"}}, 10) - - if err := s.CompleteStep("t1", -1, "x"); err == nil { - t.Error("negative index should error") - } - if err := s.CompleteStep("t1", 5, "x"); err == nil { - t.Error("out-of-range index should error") - } -} - -func TestCompleteStepNotFound(t *testing.T) { - s := NewStore(nil) - if err := s.CompleteStep("missing", 0, "x"); err == nil { - t.Error("CompleteStep on missing task should error") - } -} - -func TestComplete(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 10) - - if err := s.Complete("t1"); err != nil { - t.Fatalf("Complete: %v", err) - } - task := s.Get("t1") - if task.State != StateCompleted { - t.Errorf("State = %q, want completed", task.State) - } - if task.CompletedAt == nil { - t.Error("CompletedAt should be set") - } -} - -func TestCompleteWithJudgePass(t *testing.T) { - judge := func(task *Task) (bool, string) { - return true, "looks good" - } - s := NewStore(judge) - s.Create("t1", "T", "d", nil, 10) - - if err := s.Complete("t1"); err != nil { - t.Fatalf("Complete: %v", err) - } - task := s.Get("t1") - if !task.JudgeVerified { - t.Error("JudgeVerified should be true") - } - if task.JudgeResult != "looks good" { - t.Errorf("JudgeResult = %q", task.JudgeResult) - } - if task.State != StateCompleted { - t.Errorf("State = %q, want completed", task.State) - } -} - -func TestCompleteWithJudgeReject(t *testing.T) { - judge := func(task *Task) (bool, string) { - return false, "not good enough" - } - s := NewStore(judge) - s.Create("t1", "T", "d", nil, 10) - - err := s.Complete("t1") - if err == nil { - t.Fatal("Complete should error when judge rejects") - } - task := s.Get("t1") - if task.State != StateFailed { - t.Errorf("State = %q, want failed after judge rejection", task.State) - } - if !task.JudgeVerified { - t.Error("JudgeVerified should be true") - } -} - -func TestCompleteNotFound(t *testing.T) { - s := NewStore(nil) - if err := s.Complete("missing"); err == nil { - t.Error("Complete on missing task should error") - } -} - -func TestList(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T1", "d1", nil, 10) - s.Create("t2", "T2", "d2", nil, 10) - - tasks := s.List() - if len(tasks) != 2 { - t.Errorf("List returned %d tasks, want 2", len(tasks)) - } -} - -func TestListActive(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T1", "d1", nil, 10) - s.Create("t2", "T2", "d2", nil, 10) - s.Create("t3", "T3", "d3", nil, 10) - - s.Activate("t1") // active - s.AddHandoff("t2", Handoff{Summary: "h"}) // waiting - // t3 remains pending - - active := s.ListActive() - if len(active) != 2 { - t.Errorf("ListActive returned %d, want 2 (active + waiting)", len(active)) - } -} - -func TestConcurrentAccess(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 100) - - done := make(chan struct{}) - go func() { - for i := 0; i < 50; i++ { - s.Activate("t1") - } - close(done) - }() - - for i := 0; i < 50; i++ { - s.Get("t1") - } - <-done -} - -func TestHandoffAutoIncrementID(t *testing.T) { - s := NewStore(nil) - s.Create("t1", "T", "d", nil, 10) - - for i := 1; i <= 3; i++ { - s.AddHandoff("t1", Handoff{Summary: "h"}) - task := s.Get("t1") - if task.Handoffs[i-1].ID != i { - t.Errorf("handoff %d ID = %d, want %d", i, task.Handoffs[i-1].ID, i) - } - } -} - -func TestCreatedAtSet(t *testing.T) { - before := time.Now() - s := NewStore(nil) - task, _ := s.Create("t1", "T", "d", nil, 10) - after := time.Now() - - if task.CreatedAt.Before(before) || task.CreatedAt.After(after) { - t.Errorf("CreatedAt %v not between %v and %v", task.CreatedAt, before, after) - } -} diff --git a/internal/tools/lsp_tools.go b/internal/tools/lsp_tools.go deleted file mode 100644 index 6aa33e922..000000000 --- a/internal/tools/lsp_tools.go +++ /dev/null @@ -1,433 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - - "github.com/GrayCodeAI/hawk/internal/lsp" -) - -// LSPStatusTool lists configured language servers and their state. -type LSPStatusTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPStatusTool) Name() string { return "lsp_status" } -func (t *LSPStatusTool) Description() string { - return "List configured LSP servers and their connection state." -} -func (t *LSPStatusTool) RiskLevel() string { return "low" } -func (t *LSPStatusTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - } -} - -func (t *LSPStatusTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - status := t.Manager.Status() - var lines []string - for lang, state := range status { - lines = append(lines, fmt.Sprintf(" %s: %s", lang, state)) - } - if len(lines) == 0 { - return "No LSP servers configured.", nil - } - return "LSP Servers:\n" + strings.Join(lines, "\n"), nil -} - -// LSPDiagnosticsTool gets diagnostics for a file. -type LSPDiagnosticsTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPDiagnosticsTool) Name() string { return "lsp_diagnostics" } -func (t *LSPDiagnosticsTool) Description() string { - return "Get LSP diagnostics (errors, warnings) for a file." -} -func (t *LSPDiagnosticsTool) RiskLevel() string { return "low" } -func (t *LSPDiagnosticsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "File path to check diagnostics for", - }, - }, - "required": []string{"path"}, - } -} - -func (t *LSPDiagnosticsTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var diagnostics []lsp.Diagnostic - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, true, func(c *lsp.LSPClient, uri string) error { - var derr error - diagnostics, derr = c.Diagnostics(ctx, uri) - return derr - }) - if err != nil { - return "", err - } - if len(diagnostics) == 0 { - return "No diagnostics found.", nil - } - var lines []string - for _, d := range diagnostics { - sev := severityName(d.Severity) - code := "" - if d.Code != "" { - code = " (" + d.Code + ")" - } - lines = append(lines, fmt.Sprintf(" Line %d: %s%s — %s", d.Range.Start.Line+1, sev, code, d.Message)) - } - return fmt.Sprintf("Diagnostics for %s:\n%s", filepath.Base(absPath), strings.Join(lines, "\n")), nil -} - -// LSPGotoDefinitionTool navigates to the definition of a symbol. -type LSPGotoDefinitionTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPGotoDefinitionTool) Name() string { return "lsp_goto_definition" } -func (t *LSPGotoDefinitionTool) Description() string { - return "Go to the definition of a symbol at a position in a file." -} -func (t *LSPGotoDefinitionTool) RiskLevel() string { return "low" } -func (t *LSPGotoDefinitionTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - "line": map[string]interface{}{"type": "integer", "description": "Line number (1-based)"}, - "character": map[string]interface{}{"type": "integer", "description": "Column number (0-based)"}, - }, - "required": []string{"path", "line", "character"}, - } -} - -func (t *LSPGotoDefinitionTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - Line int `json:"line"` - Character int `json:"character"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var locations []lsp.Location - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, true, func(c *lsp.LSPClient, uri string) error { - var derr error - locations, derr = c.GotoDefinition(ctx, uri, args.Line-1, args.Character) - return derr - }) - if err != nil { - return "", err - } - if len(locations) == 0 { - return "No definition found.", nil - } - var lines []string - for _, loc := range locations { - lines = append(lines, fmt.Sprintf(" %s:%d:%d", strings.TrimPrefix(loc.URI, "file://"), loc.Range.Start.Line+1, loc.Range.Start.Character)) - } - return "Definitions:\n" + strings.Join(lines, "\n"), nil -} - -// LSPFindReferencesTool finds all references to a symbol. -type LSPFindReferencesTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPFindReferencesTool) Name() string { return "lsp_find_references" } -func (t *LSPFindReferencesTool) Description() string { - return "Find all references to a symbol at a position in a file." -} -func (t *LSPFindReferencesTool) RiskLevel() string { return "low" } -func (t *LSPFindReferencesTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - "line": map[string]interface{}{"type": "integer", "description": "Line number (1-based)"}, - "character": map[string]interface{}{"type": "integer", "description": "Column number (0-based)"}, - }, - "required": []string{"path", "line", "character"}, - } -} - -func (t *LSPFindReferencesTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - Line int `json:"line"` - Character int `json:"character"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var locations []lsp.Location - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, true, func(c *lsp.LSPClient, uri string) error { - var derr error - locations, derr = c.FindReferences(ctx, uri, args.Line-1, args.Character) - return derr - }) - if err != nil { - return "", err - } - if len(locations) == 0 { - return "No references found.", nil - } - var lines []string - for _, loc := range locations { - lines = append(lines, fmt.Sprintf(" %s:%d:%d", strings.TrimPrefix(loc.URI, "file://"), loc.Range.Start.Line+1, loc.Range.Start.Character)) - } - return fmt.Sprintf("References (%d):\n%s", len(locations), strings.Join(lines, "\n")), nil -} - -// LSPSymbolsTool lists all symbols in a document. -type LSPSymbolsTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPSymbolsTool) Name() string { return "lsp_symbols" } -func (t *LSPSymbolsTool) Description() string { - return "List all symbols (functions, types, variables) in a file." -} -func (t *LSPSymbolsTool) RiskLevel() string { return "low" } -func (t *LSPSymbolsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - }, - "required": []string{"path"}, - } -} - -func (t *LSPSymbolsTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var symbols []lsp.SymbolInformation - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, true, func(c *lsp.LSPClient, uri string) error { - var derr error - symbols, derr = c.DocumentSymbol(ctx, uri) - return derr - }) - if err != nil { - return "", err - } - if len(symbols) == 0 { - return "No symbols found.", nil - } - var lines []string - for _, s := range symbols { - kind := symbolKindName(s.Kind) - lines = append(lines, fmt.Sprintf(" %s %s (line %d)", kind, s.Name, s.Location.Range.Start.Line+1)) - } - return fmt.Sprintf("Symbols in %s:\n%s", filepath.Base(absPath), strings.Join(lines, "\n")), nil -} - -// LSPPrepareRenameTool checks if a symbol can be renamed. -type LSPPrepareRenameTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPPrepareRenameTool) Name() string { return "lsp_prepare_rename" } -func (t *LSPPrepareRenameTool) Description() string { - return "Check if a symbol at a position can be renamed." -} -func (t *LSPPrepareRenameTool) RiskLevel() string { return "low" } -func (t *LSPPrepareRenameTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - "line": map[string]interface{}{"type": "integer", "description": "Line number (1-based)"}, - "character": map[string]interface{}{"type": "integer", "description": "Column number (0-based)"}, - }, - "required": []string{"path", "line", "character"}, - } -} - -func (t *LSPPrepareRenameTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - Line int `json:"line"` - Character int `json:"character"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var rng *lsp.Range - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, true, func(c *lsp.LSPClient, uri string) error { - var derr error - rng, derr = c.PrepareRename(ctx, uri, args.Line-1, args.Character) - return derr - }) - if err != nil { - return "", err - } - if rng == nil { - return "Symbol cannot be renamed at this position.", nil - } - return fmt.Sprintf("Symbol is renamable at line %d, columns %d-%d.", rng.Start.Line+1, rng.Start.Character, rng.End.Character), nil -} - -// LSPRenameTool renames a symbol across the workspace. -type LSPRenameTool struct { - Manager *lsp.LSPManager -} - -func (t *LSPRenameTool) Name() string { return "lsp_rename" } -func (t *LSPRenameTool) Description() string { - return "Rename a symbol at a position across the workspace." -} -func (t *LSPRenameTool) RiskLevel() string { return "medium" } -func (t *LSPRenameTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - "line": map[string]interface{}{"type": "integer", "description": "Line number (1-based)"}, - "character": map[string]interface{}{"type": "integer", "description": "Column number (0-based)"}, - "new_name": map[string]interface{}{"type": "string", "description": "New name for the symbol"}, - }, - "required": []string{"path", "line", "character", "new_name"}, - } -} - -func (t *LSPRenameTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - Line int `json:"line"` - Character int `json:"character"` - NewName string `json:"new_name"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", err - } - absPath, err := filepath.Abs(args.Path) - if err != nil { - return "", err - } - lang, _, ok := t.Manager.Config().ServerForFile(absPath) - if !ok { - return fmt.Sprintf("No LSP server configured for %s", filepath.Ext(absPath)), nil - } - var edit *lsp.WorkspaceEdit - err = t.Manager.ExecuteTransient(ctx, filepath.Dir(absPath), absPath, lang, false, func(c *lsp.LSPClient, uri string) error { - var derr error - edit, derr = c.Rename(ctx, uri, args.Line-1, args.Character, args.NewName) - return derr - }) - if err != nil { - return "", err - } - if edit == nil || len(edit.Changes) == 0 { - return "No changes needed.", nil - } - totalEdits := 0 - for _, edits := range edit.Changes { - totalEdits += len(edits) - } - return fmt.Sprintf("Rename to %q: %d edits across %d files.", args.NewName, totalEdits, len(edit.Changes)), nil -} - -// RegisterLSPTools adds all 7 LSP tools to the provided slice. -func RegisterLSPTools(tools []interface{ Name() string }, manager *lsp.LSPManager) []interface{ Name() string } { - return append( - tools, - &LSPStatusTool{Manager: manager}, - &LSPDiagnosticsTool{Manager: manager}, - &LSPGotoDefinitionTool{Manager: manager}, - &LSPFindReferencesTool{Manager: manager}, - &LSPSymbolsTool{Manager: manager}, - &LSPPrepareRenameTool{Manager: manager}, - &LSPRenameTool{Manager: manager}, - ) -} - -// Helpers - -func severityName(sev int) string { - switch lsp.DiagnosticSeverity(sev) { - case lsp.SeverityError: - return "error" - case lsp.SeverityWarning: - return "warning" - case lsp.SeverityInformation: - return "info" - case lsp.SeverityHint: - return "hint" - default: - return "unknown" - } -} - -func symbolKindName(kind int) string { - kinds := map[int]string{ - 1: "File", 2: "Module", 3: "Namespace", 4: "Package", 5: "Class", - 6: "Method", 7: "Property", 8: "Field", 9: "Constructor", 10: "Enum", - 11: "Interface", 12: "Function", 13: "Variable", 14: "Constant", - 15: "String", 16: "Number", 17: "Boolean", 18: "Array", 19: "Object", - 20: "Key", 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", - 25: "Operator", 26: "TypeParameter", - } - if name, ok := kinds[kind]; ok { - return name - } - return "Symbol" -} diff --git a/internal/tools/lsp_tools_execute_test.go b/internal/tools/lsp_tools_execute_test.go deleted file mode 100644 index 3e1e31b69..000000000 --- a/internal/tools/lsp_tools_execute_test.go +++ /dev/null @@ -1,566 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/GrayCodeAI/hawk/internal/lsp" -) - -// helper to create a manager with a Go server configured -func newTestManager() *lsp.LSPManager { - cfg := &lsp.LSPConfig{ - Servers: map[string]lsp.ServerConfig{ - "go": {Command: "gopls", Extensions: []string{".go"}}, - }, - } - return lsp.NewManager(cfg) -} - -// helper to create a manager with a non-existent server command -func newFailingManager() *lsp.LSPManager { - cfg := &lsp.LSPConfig{ - Servers: map[string]lsp.ServerConfig{ - "go": {Command: "nonexistent-lsp-server-xyz", Extensions: []string{".go"}}, - }, - } - return lsp.NewManager(cfg) -} - -// --- Metadata tests --- - -func TestLSPStatusTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPStatusTool{Manager: m} - - if tool.Name() != "lsp_status" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_status") - } - if tool.Description() == "" { - t.Error("Description() should not be empty") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } - params := tool.Parameters() - if params["type"] != "object" { - t.Errorf("Parameters() type = %v, want %q", params["type"], "object") - } -} - -func TestLSPDiagnosticsTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPDiagnosticsTool{Manager: m} - - if tool.Name() != "lsp_diagnostics" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_diagnostics") - } - if tool.Description() == "" { - t.Error("Description() should not be empty") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } - params := tool.Parameters() - if _, ok := params["properties"]; !ok { - t.Error("Parameters() should have properties") - } -} - -func TestLSPGotoDefinitionTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPGotoDefinitionTool{Manager: m} - - if tool.Name() != "lsp_goto_definition" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_goto_definition") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } -} - -func TestLSPFindReferencesTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPFindReferencesTool{Manager: m} - - if tool.Name() != "lsp_find_references" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_find_references") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } -} - -func TestLSPSymbolsTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPSymbolsTool{Manager: m} - - if tool.Name() != "lsp_symbols" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_symbols") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } -} - -func TestLSPPrepareRenameTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPPrepareRenameTool{Manager: m} - - if tool.Name() != "lsp_prepare_rename" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_prepare_rename") - } - if tool.RiskLevel() != "low" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "low") - } -} - -func TestLSPRenameTool_Metadata(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPRenameTool{Manager: m} - - if tool.Name() != "lsp_rename" { - t.Errorf("Name() = %q, want %q", tool.Name(), "lsp_rename") - } - if tool.RiskLevel() != "medium" { - t.Errorf("RiskLevel() = %q, want %q", tool.RiskLevel(), "medium") - } -} - -// --- LSPStatusTool Execute tests --- - -func TestLSPStatusTool_Execute_EmptyConfig(t *testing.T) { - cfg := &lsp.LSPConfig{Servers: map[string]lsp.ServerConfig{}} - m := lsp.NewManager(cfg) - defer m.Close() - - tool := &LSPStatusTool{Manager: m} - result, err := tool.Execute(context.Background(), json.RawMessage(`{}`)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != "No LSP servers configured." { - t.Errorf("Execute() = %q, want %q", result, "No LSP servers configured.") - } -} - -func TestLSPStatusTool_Execute_WithServers(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPStatusTool{Manager: m} - result, err := tool.Execute(context.Background(), json.RawMessage(`{}`)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "LSP Servers:") { - t.Errorf("Execute() should contain 'LSP Servers:', got %q", result) - } - if !strings.Contains(result, "go:") { - t.Errorf("Execute() should contain 'go:', got %q", result) - } - if !strings.Contains(result, "available") { - t.Errorf("Execute() should contain 'available', got %q", result) - } -} - -// --- LSPDiagnosticsTool Execute tests --- - -func TestLSPDiagnosticsTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPDiagnosticsTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPDiagnosticsTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPDiagnosticsTool{Manager: m} - input, _ := json.Marshal(map[string]string{"path": "test.py"}) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPDiagnosticsTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPDiagnosticsTool{Manager: m} - input, _ := json.Marshal(map[string]string{"path": "main.go"}) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- LSPGotoDefinitionTool Execute tests --- - -func TestLSPGotoDefinitionTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPGotoDefinitionTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPGotoDefinitionTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPGotoDefinitionTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.py", - "line": 10, - "character": 5, - }) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPGotoDefinitionTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPGotoDefinitionTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "main.go", - "line": 10, - "character": 5, - }) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- LSPFindReferencesTool Execute tests --- - -func TestLSPFindReferencesTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPFindReferencesTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPFindReferencesTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPFindReferencesTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.py", - "line": 10, - "character": 5, - }) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPFindReferencesTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPFindReferencesTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "main.go", - "line": 10, - "character": 5, - }) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- LSPSymbolsTool Execute tests --- - -func TestLSPSymbolsTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPSymbolsTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPSymbolsTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPSymbolsTool{Manager: m} - input, _ := json.Marshal(map[string]string{"path": "test.py"}) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPSymbolsTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPSymbolsTool{Manager: m} - input, _ := json.Marshal(map[string]string{"path": "main.go"}) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- LSPPrepareRenameTool Execute tests --- - -func TestLSPPrepareRenameTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPPrepareRenameTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPPrepareRenameTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPPrepareRenameTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.py", - "line": 10, - "character": 5, - }) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPPrepareRenameTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPPrepareRenameTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "main.go", - "line": 10, - "character": 5, - }) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- LSPRenameTool Execute tests --- - -func TestLSPRenameTool_Execute_InvalidJSON(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPRenameTool{Manager: m} - _, err := tool.Execute(context.Background(), json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLSPRenameTool_Execute_NoServerForFile(t *testing.T) { - m := newTestManager() - defer m.Close() - - tool := &LSPRenameTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.py", - "line": 10, - "character": 5, - "new_name": "newName", - }) - result, err := tool.Execute(context.Background(), input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "No LSP server configured for .py") { - t.Errorf("Execute() should mention no LSP server for .py, got %q", result) - } -} - -func TestLSPRenameTool_Execute_ServerError(t *testing.T) { - m := newFailingManager() - defer m.Close() - - tool := &LSPRenameTool{Manager: m} - input, _ := json.Marshal(map[string]interface{}{ - "path": "main.go", - "line": 10, - "character": 5, - "new_name": "newName", - }) - _, err := tool.Execute(context.Background(), input) - if err == nil { - t.Error("expected error when server fails to start") - } -} - -// --- RegisterLSPTools tests --- - -func TestRegisterLSPTools_WithExisting(t *testing.T) { - m := newTestManager() - defer m.Close() - - existing := []interface{ Name() string }{ - &mockTool{name: "existing_tool"}, - } - result := RegisterLSPTools(existing, m) - if len(result) != 8 { - t.Errorf("expected 8 tools (1 existing + 7 new), got %d", len(result)) - } - if result[0].Name() != "existing_tool" { - t.Errorf("first tool should be existing, got %q", result[0].Name()) - } - if result[1].Name() != "lsp_status" { - t.Errorf("second tool should be lsp_status, got %q", result[1].Name()) - } -} - -func TestRegisterLSPTools_NilSlice(t *testing.T) { - m := newTestManager() - defer m.Close() - - result := RegisterLSPTools(nil, m) - if len(result) != 7 { - t.Errorf("expected 7 tools, got %d", len(result)) - } -} - -// --- mock tool for testing --- - -type mockTool struct { - name string -} - -func (m *mockTool) Name() string { return m.name } - -// --- Parameters tests --- - -func TestLSPDiagnosticsTool_Parameters(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPDiagnosticsTool{Manager: m} - - params := tool.Parameters() - props, ok := params["properties"].(map[string]interface{}) - if !ok { - t.Fatal("expected properties to be a map") - } - pathProp, ok := props["path"].(map[string]interface{}) - if !ok { - t.Fatal("expected path property") - } - if pathProp["type"] != "string" { - t.Errorf("path type = %v, want %q", pathProp["type"], "string") - } - - required, ok := params["required"].([]string) - if !ok { - t.Fatal("expected required to be a []string") - } - if len(required) != 1 || required[0] != "path" { - t.Errorf("required = %v, want [path]", required) - } -} - -func TestLSPGotoDefinitionTool_Parameters(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPGotoDefinitionTool{Manager: m} - - params := tool.Parameters() - props, ok := params["properties"].(map[string]interface{}) - if !ok { - t.Fatal("expected properties to be a map") - } - for _, field := range []string{"path", "line", "character"} { - if _, exists := props[field]; !exists { - t.Errorf("expected %q in properties", field) - } - } - - required, ok := params["required"].([]string) - if !ok { - t.Fatal("expected required to be a []string") - } - if len(required) != 3 { - t.Errorf("expected 3 required fields, got %d", len(required)) - } -} - -func TestLSPRenameTool_Parameters(t *testing.T) { - m := newTestManager() - defer m.Close() - tool := &LSPRenameTool{Manager: m} - - params := tool.Parameters() - props, ok := params["properties"].(map[string]interface{}) - if !ok { - t.Fatal("expected properties to be a map") - } - for _, field := range []string{"path", "line", "character", "new_name"} { - if _, exists := props[field]; !exists { - t.Errorf("expected %q in properties", field) - } - } - - required, ok := params["required"].([]string) - if !ok { - t.Fatal("expected required to be a []string") - } - if len(required) != 4 { - t.Errorf("expected 4 required fields, got %d", len(required)) - } -} diff --git a/internal/tools/lsp_tools_metadata_test.go b/internal/tools/lsp_tools_metadata_test.go deleted file mode 100644 index 6a6294269..000000000 --- a/internal/tools/lsp_tools_metadata_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "testing" -) - -// TestLSPTools_Metadata verifies that all LSP tool implementations return -// non-empty metadata (Name, Description, RiskLevel, Parameters). -func TestLSPTools_Metadata(t *testing.T) { - mgr := newTestManager() - defer mgr.Close() - - tools := []struct { - name string - tool interface { - Name() string - Description() string - RiskLevel() string - Parameters() map[string]interface{} - } - }{ - {"GotoDefinition", &LSPGotoDefinitionTool{Manager: mgr}}, - {"FindReferences", &LSPFindReferencesTool{Manager: mgr}}, - {"Symbols", &LSPSymbolsTool{Manager: mgr}}, - {"PrepareRename", &LSPPrepareRenameTool{Manager: mgr}}, - {"Rename", &LSPRenameTool{Manager: mgr}}, - } - - for _, tc := range tools { - t.Run(tc.name, func(t *testing.T) { - if name := tc.tool.Name(); name == "" { - t.Error("Name() returned empty string") - } - if desc := tc.tool.Description(); desc == "" { - t.Error("Description() returned empty string") - } - if risk := tc.tool.RiskLevel(); risk == "" { - t.Error("RiskLevel() returned empty string") - } - params := tc.tool.Parameters() - if params == nil { - t.Fatal("Parameters() returned nil") - } - if _, ok := params["type"]; !ok { - t.Error("Parameters() missing 'type' field") - } - if _, ok := params["properties"]; !ok { - t.Error("Parameters() missing 'properties' field") - } - if _, ok := params["required"]; !ok { - t.Error("Parameters() missing 'required' field") - } - }) - } -} - -// TestLSPTools_Execute_NoServer verifies that tools return a helpful message -// when no LSP server is configured for the file type. -func TestLSPTools_Execute_NoServer(t *testing.T) { - mgr := newTestManager() - defer mgr.Close() - - ctx := context.Background() - - // GotoDefinition with unknown file type - t.Run("GotoDefinition_NoServer", func(t *testing.T) { - tool := &LSPGotoDefinitionTool{Manager: mgr} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.unknownext", - "line": 1, - "character": 0, - }) - result, err := tool.Execute(ctx, input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result == "" { - t.Error("expected non-empty result for no server") - } - }) - - // FindReferences with unknown file type - t.Run("FindReferences_NoServer", func(t *testing.T) { - tool := &LSPFindReferencesTool{Manager: mgr} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.unknownext", - "line": 1, - "character": 0, - }) - result, err := tool.Execute(ctx, input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result == "" { - t.Error("expected non-empty result for no server") - } - }) - - // Symbols with unknown file type - t.Run("Symbols_NoServer", func(t *testing.T) { - tool := &LSPSymbolsTool{Manager: mgr} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.unknownext", - }) - result, err := tool.Execute(ctx, input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result == "" { - t.Error("expected non-empty result for no server") - } - }) - - // PrepareRename with unknown file type - t.Run("PrepareRename_NoServer", func(t *testing.T) { - tool := &LSPPrepareRenameTool{Manager: mgr} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.unknownext", - "line": 1, - "character": 0, - }) - result, err := tool.Execute(ctx, input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result == "" { - t.Error("expected non-empty result for no server") - } - }) - - // Rename with unknown file type - t.Run("Rename_NoServer", func(t *testing.T) { - tool := &LSPRenameTool{Manager: mgr} - input, _ := json.Marshal(map[string]interface{}{ - "path": "test.unknownext", - "line": 1, - "character": 0, - "new_name": "newName", - }) - result, err := tool.Execute(ctx, input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result == "" { - t.Error("expected non-empty result for no server") - } - }) -} - -// TestLSPTools_Execute_InvalidInput verifies that tools handle invalid JSON input gracefully. -func TestLSPTools_Execute_InvalidInput(t *testing.T) { - mgr := newTestManager() - defer mgr.Close() - - ctx := context.Background() - - t.Run("GotoDefinition_InvalidJSON", func(t *testing.T) { - tool := &LSPGotoDefinitionTool{Manager: mgr} - _, err := tool.Execute(ctx, json.RawMessage("not valid json")) - if err == nil { - t.Error("expected error for invalid JSON input") - } - }) - - t.Run("FindReferences_InvalidJSON", func(t *testing.T) { - tool := &LSPFindReferencesTool{Manager: mgr} - _, err := tool.Execute(ctx, json.RawMessage("not valid json")) - if err == nil { - t.Error("expected error for invalid JSON input") - } - }) - - t.Run("Symbols_InvalidJSON", func(t *testing.T) { - tool := &LSPSymbolsTool{Manager: mgr} - _, err := tool.Execute(ctx, json.RawMessage("not valid json")) - if err == nil { - t.Error("expected error for invalid JSON input") - } - }) - - t.Run("PrepareRename_InvalidJSON", func(t *testing.T) { - tool := &LSPPrepareRenameTool{Manager: mgr} - _, err := tool.Execute(ctx, json.RawMessage("not valid json")) - if err == nil { - t.Error("expected error for invalid JSON input") - } - }) - - t.Run("Rename_InvalidJSON", func(t *testing.T) { - tool := &LSPRenameTool{Manager: mgr} - _, err := tool.Execute(ctx, json.RawMessage("not valid json")) - if err == nil { - t.Error("expected error for invalid JSON input") - } - }) -} diff --git a/internal/tools/lsp_tools_test.go b/internal/tools/lsp_tools_test.go deleted file mode 100644 index 56cf281d4..000000000 --- a/internal/tools/lsp_tools_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package tools - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/internal/lsp" -) - -func TestLSPToolNames(t *testing.T) { - cfg := &lsp.LSPConfig{Servers: map[string]lsp.ServerConfig{ - "go": {Command: "gopls", Extensions: []string{".go"}}, - }} - m := lsp.NewManager(cfg) - defer m.Close() - - tools := RegisterLSPTools(nil, m) - expected := []string{ - "lsp_status", "lsp_diagnostics", "lsp_goto_definition", - "lsp_find_references", "lsp_symbols", "lsp_prepare_rename", "lsp_rename", - } - if len(tools) != len(expected) { - t.Fatalf("expected %d tools, got %d", len(expected), len(tools)) - } - for i, name := range expected { - if tools[i].Name() != name { - t.Errorf("tool[%d]: expected %q, got %q", i, name, tools[i].Name()) - } - } -} - -func TestSeverityName(t *testing.T) { - tests := []struct { - sev int - want string - }{ - {1, "error"}, - {2, "warning"}, - {3, "info"}, - {4, "hint"}, - {99, "unknown"}, - } - for _, tt := range tests { - if got := severityName(tt.sev); got != tt.want { - t.Errorf("severityName(%d) = %q, want %q", tt.sev, got, tt.want) - } - } -} - -func TestSymbolKindName(t *testing.T) { - tests := []struct { - kind int - want string - }{ - {6, "Method"}, - {12, "Function"}, - {13, "Variable"}, - {5, "Class"}, - {23, "Struct"}, - {999, "Symbol"}, - } - for _, tt := range tests { - if got := symbolKindName(tt.kind); got != tt.want { - t.Errorf("symbolKindName(%d) = %q, want %q", tt.kind, got, tt.want) - } - } -} diff --git a/internal/tui/diff/diff.go b/internal/tui/diff/diff.go deleted file mode 100644 index 2e943f003..000000000 --- a/internal/tui/diff/diff.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package diff implements the core of a differential terminal renderer: it -// compares consecutive rendered frames and emits only the changed lines, -// wrapped in synchronized-output sequences to avoid flicker/tearing. -// -// This mirrors the algorithm in earendil-works/pi-tui: render the full frame, -// diff against the previous frame, then re-emit only the changed range with the -// cursor positioned at the first changed row and each changed line cleared -// before rewrite. It is a self-contained, testable unit that a terminal render -// loop can adopt without coupling to any specific agent runtime. -package diff - -import ( - "fmt" - "io" - "strings" -) - -// Range describes the contiguous changed line region between two frames. -type Range struct { - First int // first changed line (0-based) - Last int // last changed line (0-based, inclusive) - Changed bool // true when any line changed - Appended bool // true when lines were appended beyond the previous frame -} - -// Changed compares prev and next frames line by line and returns the minimal -// contiguous range covering every difference. Appended lines extend the range -// to the end of next. A nil/empty next returns Changed=false. -func Changed(prev, next []string) Range { - if len(next) == 0 { - return Range{} - } - first := -1 - last := -1 - limit := len(prev) - if len(next) < limit { - limit = len(next) - } - for i := 0; i < limit; i++ { - if prev[i] != next[i] { - if first < 0 { - first = i - } - last = i - } - } - // Appended lines extend the range to the end of next. - if len(next) > len(prev) { - if first < 0 { - first = len(prev) - } - last = len(next) - 1 - } - if first < 0 { - return Range{} - } - return Range{First: first, Last: last, Changed: true, Appended: len(next) > len(prev)} -} - -// SynchronizedRender emits only the changed lines from prev to next, wrapped in -// synchronized-output sequences. It writes the sync-open, moves the cursor to -// the first changed row, clears and rewrites each changed line, and closes the -// sync block. It returns the number of lines emitted. When nothing changed it -// writes nothing. -func SynchronizedRender(w io.Writer, prev, next []string) (int, error) { - changed := Changed(prev, next) - if !changed.Changed { - return 0, nil - } - var b strings.Builder - // Synchronized output: open the marker block before any emission. - b.WriteString("\x1b[?2026h") - b.WriteString(fmt.Sprintf("\x1b[%d;1H", changed.First+1)) // move to first changed row - for i := changed.First; i <= changed.Last; i++ { - b.WriteString("\x1b[2K") // clear the line - b.WriteString(next[i]) - b.WriteString("\r\n") - } - b.WriteString("\x1b[?2026l") - _, err := io.WriteString(w, b.String()) - if err != nil { - return 0, err - } - return changed.Last - changed.First + 1, nil -} diff --git a/internal/tui/diff/diff_test.go b/internal/tui/diff/diff_test.go deleted file mode 100644 index e40b31a14..000000000 --- a/internal/tui/diff/diff_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package diff - -import ( - "strings" - "testing" -) - -func TestChangedNoChange(t *testing.T) { - prev := []string{"a", "b", "c"} - next := []string{"a", "b", "c"} - r := Changed(prev, next) - if r.Changed { - t.Fatal("identical frames must not be reported as changed") - } -} - -func TestChangedSingleLine(t *testing.T) { - prev := []string{"a", "b", "c"} - next := []string{"a", "B", "c"} - r := Changed(prev, next) - if !r.Changed || r.First != 1 || r.Last != 1 { - t.Fatalf("single-line change range = %+v, want {First:1 Last:1}", r) - } -} - -func TestChangedAppend(t *testing.T) { - prev := []string{"a", "b"} - next := []string{"a", "b", "c", "d"} - r := Changed(prev, next) - if !r.Changed || !r.Appended || r.First != 2 || r.Last != 3 { - t.Fatalf("append range = %+v, want {First:2 Last:3 Appended:true}", r) - } -} - -func TestChangedShrink(t *testing.T) { - prev := []string{"a", "b", "c"} - next := []string{"a", "X"} - r := Changed(prev, next) - if !r.Changed || r.First != 1 || r.Last != 1 { - t.Fatalf("shrink range = %+v, want {First:1 Last:1}", r) - } -} - -func TestSynchronizedRenderEmitsOnlyChanged(t *testing.T) { - prev := []string{"line0", "line1", "line2"} - next := []string{"line0", "CHANGED", "line2"} - var out strings.Builder - n, err := SynchronizedRender(&out, prev, next) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("expected 1 line emitted, got %d", n) - } - s := out.String() - if !strings.Contains(s, "\x1b[?2026h") || !strings.Contains(s, "\x1b[?2026l") { - t.Fatal("render must wrap output in synchronized sequences") - } - if !strings.Contains(s, "CHANGED") { - t.Fatal("render must contain the changed line") - } - if strings.Contains(s, "line0") { - t.Fatal("render must not re-emit unchanged lines") - } -} - -func TestSynchronizedRenderNoChangeWritesNothing(t *testing.T) { - prev := []string{"a", "b"} - next := []string{"a", "b"} - var out strings.Builder - n, err := SynchronizedRender(&out, prev, next) - if err != nil || n != 0 { - t.Fatalf("no-change render: n=%d err=%v, want 0,nil", n, err) - } - if out.Len() != 0 { - t.Fatalf("no-change render must write nothing, got %q", out.String()) - } -}