diff --git a/cmd/notify_test.go b/cmd/notify_test.go index b7cfc982a..38276703a 100644 --- a/cmd/notify_test.go +++ b/cmd/notify_test.go @@ -246,39 +246,47 @@ func TestFormatHistoryEmpty(t *testing.T) { } func TestSetTerminalTitle(t *testing.T) { - // We can't easily capture os.Stdout in a test, but we can verify - // the escape sequence format by testing the function doesn't panic + // We can't easily capture os.Stdout in a test, but we can verify the + // notifier is configured and the calls do not panic. n := NewNotifier() n.Sound = false n.Desktop = false + if n == nil || !n.Enabled { + t.Fatal("expected an enabled notifier") + } + if n.Sound || n.Desktop { + t.Fatal("expected Sound and Desktop to be disabled") + } - // Just verify it doesn't panic n.SetTerminalTitle("rho: running") n.ClearTitle() } func TestBellCharacter(t *testing.T) { - // Verify Bell doesn't panic n := NewNotifier() + if n == nil { + t.Fatal("expected a notifier") + } n.Bell() } func TestDesktopNotifyCommandConstruction(t *testing.T) { - // We can't actually send desktop notifications in tests, - // but we can verify the function handles various inputs without panic + // Verify the AppleScript escaper neutralizes quotes and backslashes; the + // actual notification command may not exist in CI. + if got := escapeAppleScript(`a"b\c`); strings.Contains(got, `"`) && !strings.Contains(got, `\"`) { + t.Errorf("unescaped quote in %q", got) + } n := NewNotifier() - - // The actual command will likely fail in CI, but should not panic - err := n.DesktopNotify("Test Title", "Test Message") - // We don't assert on error because the notification tool may not be available - _ = err + _ = n.DesktopNotify("Test Title", "Test Message") } func TestDesktopNotifySpecialCharacters(t *testing.T) { n := NewNotifier() - // Test with special characters that need escaping - err := n.DesktopNotify(`Title with "quotes"`, `Message with "quotes" and \ backslash`) - _ = err + // Test with special characters that need escaping. + _ = n.DesktopNotify(`Title with "quotes"`, `Message with "quotes" and \ backslash`) + if got := escapeAppleScript(`"quoted"`); !strings.Contains(got, `\"`) { + t.Errorf("expected escaped quotes, got %q", got) + } } func TestHistoryLimit(t *testing.T) { diff --git a/cmd/slash_commands_test.go b/cmd/slash_commands_test.go index ce2287a9d..051237d4c 100644 --- a/cmd/slash_commands_test.go +++ b/cmd/slash_commands_test.go @@ -85,20 +85,27 @@ func TestHasString(t *testing.T) { } func TestBranchSummary(t *testing.T) { - // May produce output or empty depending on whether we're in a git repo + // May produce output or empty depending on whether we're in a git repo. + // It must at least be deterministic for a fixed repo state. summary := branchSummary() - _ = summary // just verify no panic + if summary != branchSummary() { + t.Error("branchSummary is not deterministic for the same repo state") + } } func TestFilesSummary(t *testing.T) { summary := filesSummary() - _ = summary // just verify no panic + if summary != filesSummary() { + t.Error("filesSummary is not deterministic for the same repo state") + } } func TestHooksSummary(t *testing.T) { t.Parallel() summary := hooksSummary() - _ = summary + if summary != hooksSummary() { + t.Error("hooksSummary is not deterministic for the same repo state") + } } func TestApplySlashSuggestion(t *testing.T) { diff --git a/internal/engine/errs/error_context.go b/internal/engine/errs/error_context.go index f11031596..71d7dc120 100644 --- a/internal/engine/errs/error_context.go +++ b/internal/engine/errs/error_context.go @@ -34,10 +34,19 @@ type EnrichedError struct { func NewErrorContext() *ErrorContext { ec := &ErrorContext{ - Patterns: make(map[string]*ErrorHelp), + Patterns: make(map[string]*ErrorHelp, len(builtinErrorPatterns)), } + for key, help := range builtinErrorPatterns { + ec.Patterns[key] = help + } + return ec +} - ec.Patterns["go_undefined"] = &ErrorHelp{ +// builtinErrorPatterns is the static error-pattern table consulted by +// ErrorContext. Keeping it as data (rather than a 500-line constructor) makes +// it reviewable and keeps NewErrorContext small. +var builtinErrorPatterns = map[string]*ErrorHelp{ + "go_undefined": { Pattern: regexp.MustCompile(`undefined:\s*(\w+)`), Title: "Undefined identifier", Explanation: "The identifier is used but has not been declared in the current scope. This can happen when a variable, function, or type is misspelled, not imported, or declared in a different scope.", @@ -52,9 +61,9 @@ func NewErrorContext() *ErrorContext { "import \"fmt\" // import the needed package", }, DocURL: "https://go.dev/ref/spec#Declarations_and_scope", - } + }, - ec.Patterns["go_type_mismatch"] = &ErrorHelp{ + "go_type_mismatch": { Pattern: regexp.MustCompile(`cannot use .+ as .+ in`), Title: "Type mismatch", Explanation: "A value of one type was used where a different type was expected. Go is strictly typed and does not perform implicit type conversions.", @@ -69,9 +78,9 @@ func NewErrorContext() *ErrorContext { "strconv.Itoa(num) // int to string", }, DocURL: "https://go.dev/ref/spec#Conversions", - } + }, - ec.Patterns["go_import_cycle"] = &ErrorHelp{ + "go_import_cycle": { Pattern: regexp.MustCompile(`import cycle not allowed`), Title: "Import cycle detected", Explanation: "Two or more packages import each other, creating a circular dependency. Go does not allow import cycles. This usually indicates a design issue where shared types or interfaces should be extracted to a separate package.", @@ -86,9 +95,9 @@ func NewErrorContext() *ErrorContext { "// Use an interface in package A instead of importing package B directly", }, DocURL: "https://go.dev/doc/faq#mutual_import", - } + }, - ec.Patterns["go_too_many_args"] = &ErrorHelp{ + "go_too_many_args": { Pattern: regexp.MustCompile(`too many arguments`), Title: "Too many arguments in function call", Explanation: "More arguments were passed to a function than its signature accepts. This often happens after refactoring when a function signature changes.", @@ -102,9 +111,9 @@ func NewErrorContext() *ErrorContext { "func foo(a, b int) {} // accepts exactly 2 args", "func bar(args ...int) {} // accepts variable args", }, - } + }, - ec.Patterns["go_not_enough_args"] = &ErrorHelp{ + "go_not_enough_args": { Pattern: regexp.MustCompile(`not enough arguments`), Title: "Not enough arguments in function call", Explanation: "Fewer arguments were passed to a function than its signature requires. Ensure all required parameters are provided.", @@ -117,9 +126,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "result := foo(a, b) // provide all required args", }, - } + }, - ec.Patterns["go_deadlock"] = &ErrorHelp{ + "go_deadlock": { Pattern: regexp.MustCompile(`(fatal error: all goroutines are asleep|deadlock)`), Title: "Goroutine deadlock", Explanation: "All goroutines are blocked waiting for each other, and no progress can be made. This typically happens when channels are used incorrectly or mutexes are locked in inconsistent order.", @@ -133,9 +142,9 @@ func NewErrorContext() *ErrorContext { "ch := make(chan int, 1) // buffered channel prevents blocking", "select {\ncase val := <-ch:\n // handle\ncase <-time.After(5 * time.Second):\n // timeout\n}", }, - } + }, - ec.Patterns["go_nil_pointer"] = &ErrorHelp{ + "go_nil_pointer": { Pattern: regexp.MustCompile(`nil pointer dereference`), Title: "Nil pointer dereference", Explanation: "A nil pointer was accessed. This means a variable was used before being initialized or after being set to nil.", @@ -149,9 +158,9 @@ func NewErrorContext() *ErrorContext { "if obj != nil {\n obj.Method()\n}", "result, err := GetObj()\nif err != nil || result == nil {\n return err\n}", }, - } + }, - ec.Patterns["py_indentation"] = &ErrorHelp{ + "py_indentation": { Pattern: regexp.MustCompile(`IndentationError`), Title: "Python indentation error", Explanation: "Python uses indentation to define code blocks. Mixing tabs and spaces or inconsistent indentation levels will cause this error.", @@ -164,9 +173,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "def foo():\n if True:\n pass # 4 spaces per level", }, - } + }, - ec.Patterns["py_import"] = &ErrorHelp{ + "py_import": { Pattern: regexp.MustCompile(`(ImportError|ModuleNotFoundError)`), Title: "Python import error", Explanation: "The module or package could not be found. It may not be installed, or the module path may be incorrect.", @@ -181,9 +190,9 @@ func NewErrorContext() *ErrorContext { "pip install requests # install missing package", "python -m venv venv && source venv/bin/activate", }, - } + }, - ec.Patterns["py_type"] = &ErrorHelp{ + "py_type": { Pattern: regexp.MustCompile(`TypeError`), Title: "Python type error", Explanation: "An operation was applied to an object of inappropriate type. This often happens when mixing incompatible types or calling a non-callable object.", @@ -197,9 +206,9 @@ func NewErrorContext() *ErrorContext { "str(42) + \" items\" # convert int to str before concatenation", "isinstance(obj, list) # check type before use", }, - } + }, - ec.Patterns["py_attribute"] = &ErrorHelp{ + "py_attribute": { Pattern: regexp.MustCompile(`AttributeError`), Title: "Python attribute error", Explanation: "An object does not have the attribute or method being accessed. This can happen when using the wrong type, a typo in the attribute name, or accessing an attribute before it is set.", @@ -213,9 +222,9 @@ func NewErrorContext() *ErrorContext { "if hasattr(obj, 'method'):\n obj.method()", "print(type(obj)) # verify the actual type", }, - } + }, - ec.Patterns["js_module_not_found"] = &ErrorHelp{ + "js_module_not_found": { Pattern: regexp.MustCompile(`Cannot find module`), Title: "Module not found", Explanation: "The required module could not be resolved. It may not be installed, the path may be wrong, or type definitions may be missing.", @@ -230,9 +239,9 @@ func NewErrorContext() *ErrorContext { "npm install lodash @types/lodash", "// Check relative path: import { foo } from './utils/foo'", }, - } + }, - ec.Patterns["js_not_a_function"] = &ErrorHelp{ + "js_not_a_function": { Pattern: regexp.MustCompile(`is not a function`), Title: "Not a function", Explanation: "A value that is not a function was invoked as one. This usually means the variable holds undefined, null, or a non-function value at the time of the call.", @@ -246,9 +255,9 @@ func NewErrorContext() *ErrorContext { "if (typeof fn === 'function') { fn(); }", "// Verify: export function myFunc() {} in the source module", }, - } + }, - ec.Patterns["js_undefined_not_object"] = &ErrorHelp{ + "js_undefined_not_object": { Pattern: regexp.MustCompile(`undefined is not an object`), Title: "Cannot access property of undefined", Explanation: "An attempt was made to access a property on undefined. This means a previous property access or function call returned undefined.", @@ -262,9 +271,9 @@ func NewErrorContext() *ErrorContext { "const name = obj?.user?.name ?? 'default';", "if (response && response.data) { /* use data */ }", }, - } + }, - ec.Patterns["git_merge_conflict"] = &ErrorHelp{ + "git_merge_conflict": { Pattern: regexp.MustCompile(`(merge conflict|CONFLICT|Merge conflict)`), Title: "Git merge conflict", Explanation: "Changes in different branches affect the same lines. Git cannot automatically determine which version to keep.", @@ -279,9 +288,9 @@ func NewErrorContext() *ErrorContext { "git status # see which files have conflicts", "git add resolved_file.go && git commit", }, - } + }, - ec.Patterns["git_not_a_repo"] = &ErrorHelp{ + "git_not_a_repo": { Pattern: regexp.MustCompile(`not a git repository`), Title: "Not a git repository", Explanation: "The current directory (or specified path) is not inside a git repository. Either initialize a new repository or navigate to the correct directory.", @@ -295,9 +304,9 @@ func NewErrorContext() *ErrorContext { "git init", "cd /path/to/project && git status", }, - } + }, - ec.Patterns["git_nothing_to_commit"] = &ErrorHelp{ + "git_nothing_to_commit": { Pattern: regexp.MustCompile(`nothing to commit`), Title: "Nothing to commit", Explanation: "There are no staged or modified files to commit. All changes have already been committed or the working directory is clean.", @@ -311,9 +320,9 @@ func NewErrorContext() *ErrorContext { "git add . && git commit -m \"message\"", "git status # check for untracked or modified files", }, - } + }, - ec.Patterns["sys_permission_denied"] = &ErrorHelp{ + "sys_permission_denied": { Pattern: regexp.MustCompile(`permission denied`), Title: "Permission denied", Explanation: "The operation was rejected due to insufficient filesystem or OS permissions. The current user does not have the required access rights.", @@ -327,9 +336,9 @@ func NewErrorContext() *ErrorContext { "chmod 644 file.txt # owner read/write, others read", "ls -la /path/to/file # check current permissions", }, - } + }, - ec.Patterns["sys_no_such_file"] = &ErrorHelp{ + "sys_no_such_file": { Pattern: regexp.MustCompile(`no such file or directory`), Title: "File or directory not found", Explanation: "The specified path does not exist. The file may have been moved, deleted, or the path may contain a typo.", @@ -344,9 +353,9 @@ func NewErrorContext() *ErrorContext { "mkdir -p /path/to/dir # create missing directories", "find . -name 'filename' # search for the file", }, - } + }, - ec.Patterns["sys_address_in_use"] = &ErrorHelp{ + "sys_address_in_use": { Pattern: regexp.MustCompile(`address already in use`), Title: "Address already in use", Explanation: "Another process is already listening on the requested port. Only one process can bind to a specific port at a time.", @@ -360,9 +369,9 @@ func NewErrorContext() *ErrorContext { "lsof -i :8080 # find process using port 8080", "kill -9 # kill the process", }, - } + }, - ec.Patterns["sys_connection_refused"] = &ErrorHelp{ + "sys_connection_refused": { Pattern: regexp.MustCompile(`connection refused`), Title: "Connection refused", Explanation: "No service is listening on the target address and port. The server may not be running, or the address/port may be incorrect.", @@ -376,9 +385,9 @@ func NewErrorContext() *ErrorContext { "curl http://localhost:8080/health # test connectivity", "netstat -tlnp | grep 8080 # check if port is listening", }, - } + }, - ec.Patterns["sys_disk_full"] = &ErrorHelp{ + "sys_disk_full": { Pattern: regexp.MustCompile(`(no space left on device|disk full)`), Title: "Disk full", Explanation: "The filesystem has no remaining free space. Write operations will fail until space is freed.", @@ -392,9 +401,9 @@ func NewErrorContext() *ErrorContext { "df -h # check free space", "du -sh /tmp/* # find large temp files", }, - } + }, - ec.Patterns["sys_timeout"] = &ErrorHelp{ + "sys_timeout": { Pattern: regexp.MustCompile(`(connection timed out|timeout|context deadline exceeded)`), Title: "Operation timed out", Explanation: "The operation did not complete within the allowed time. This could be a network issue, an overloaded server, or an operation that needs a longer timeout.", @@ -408,9 +417,9 @@ func NewErrorContext() *ErrorContext { "ctx, cancel := context.WithTimeout(ctx, 30*time.Second)", "curl --connect-timeout 10 http://example.com", }, - } + }, - ec.Patterns["rho_old_str_not_found"] = &ErrorHelp{ + "rho_old_str_not_found": { Pattern: regexp.MustCompile(`old_str not found`), Title: "Edit target string not found", Explanation: "The text specified in old_str does not exist in the file. The file may have been modified since it was last read, or the string may contain whitespace or encoding differences.", @@ -425,9 +434,9 @@ func NewErrorContext() *ErrorContext { "// Re-read the file first, then retry the edit with exact content", }, AutoFix: "Re-read the target file and retry with the exact current content", - } + }, - ec.Patterns["rho_file_too_large"] = &ErrorHelp{ + "rho_file_too_large": { Pattern: regexp.MustCompile(`file too large`), Title: "File exceeds size limit", Explanation: "The file is too large to be processed in a single operation. This protects against accidentally loading very large files into memory.", @@ -442,9 +451,9 @@ func NewErrorContext() *ErrorContext { "// head_tail tool: read first 50 and last 50 lines", }, AutoFix: "Use head_tail or line-range reads to process the file in parts", - } + }, - ec.Patterns["rho_budget_exceeded"] = &ErrorHelp{ + "rho_budget_exceeded": { Pattern: regexp.MustCompile(`budget exceeded`), Title: "Token or cost budget exceeded", Explanation: "The session has consumed more tokens or cost than the configured budget allows. This is a safety limit to prevent runaway costs.", @@ -458,9 +467,9 @@ func NewErrorContext() *ErrorContext { "rho --budget 10.00 # set a higher budget", "/compact # reduce context size", }, - } + }, - ec.Patterns["rho_tool_not_found"] = &ErrorHelp{ + "rho_tool_not_found": { Pattern: regexp.MustCompile(`(tool not found|unknown tool)`), Title: "Tool not found", Explanation: "The requested tool does not exist in the current tool registry. It may be misspelled or not available in this configuration.", @@ -473,9 +482,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "// List available tools to find the correct name", }, - } + }, - ec.Patterns["rho_sandbox_violation"] = &ErrorHelp{ + "rho_sandbox_violation": { Pattern: regexp.MustCompile(`(sandbox violation|operation not permitted by sandbox)`), Title: "Sandbox security violation", Explanation: "The operation was blocked by the sandbox security policy. The command attempted to access a resource outside the allowed scope.", @@ -488,9 +497,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "// Ensure operations target files within the project root", }, - } + }, - ec.Patterns["go_unused_import"] = &ErrorHelp{ + "go_unused_import": { Pattern: regexp.MustCompile(`imported and not used`), Title: "Unused import", Explanation: "A package was imported but none of its exported identifiers are used. Go treats unused imports as compile errors.", @@ -504,9 +513,9 @@ func NewErrorContext() *ErrorContext { "import _ \"net/http/pprof\" // side-effect import", "// Run: goimports -w file.go", }, - } + }, - ec.Patterns["go_unused_var"] = &ErrorHelp{ + "go_unused_var": { Pattern: regexp.MustCompile(`declared (and|but) not used`), Title: "Unused variable", Explanation: "A variable was declared but never referenced. Go treats unused local variables as compile errors.", @@ -518,9 +527,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "_ = unusedValue // explicitly discard", }, - } + }, - ec.Patterns["json_parse"] = &ErrorHelp{ + "json_parse": { Pattern: regexp.MustCompile(`(invalid character|unexpected end of JSON|json: cannot unmarshal)`), Title: "JSON parse error", Explanation: "The input is not valid JSON or does not match the expected structure. This can happen with malformed data, trailing commas, or type mismatches.", @@ -534,9 +543,9 @@ func NewErrorContext() *ErrorContext { "echo '{\"key\": \"value\"}' | jq . // validate JSON", "json.Unmarshal(data, &target) // ensure target matches structure", }, - } + }, - ec.Patterns["go_interface_not_implemented"] = &ErrorHelp{ + "go_interface_not_implemented": { Pattern: regexp.MustCompile(`does not implement`), Title: "Interface not satisfied", Explanation: "A type was used where an interface is expected, but it does not implement all required methods. Check the missing method signatures.", @@ -549,9 +558,9 @@ func NewErrorContext() *ErrorContext { Examples: []string{ "// Compile-time check:\nvar _ MyInterface = (*MyType)(nil)", }, - } + }, - ec.Patterns["docker_not_found"] = &ErrorHelp{ + "docker_not_found": { Pattern: regexp.MustCompile(`(docker: command not found|Cannot connect to the Docker daemon)`), Title: "Docker unavailable", Explanation: "Docker is either not installed or the daemon is not running. Docker requires both the CLI tool and a running daemon.", @@ -565,9 +574,9 @@ func NewErrorContext() *ErrorContext { "sudo systemctl start docker", "docker info # verify daemon is running", }, - } + }, - ec.Patterns["oom"] = &ErrorHelp{ + "oom": { Pattern: regexp.MustCompile(`(out of memory|OOM|cannot allocate memory)`), Title: "Out of memory", Explanation: "The process attempted to allocate more memory than is available. This can happen with large data sets, memory leaks, or insufficient system resources.", @@ -581,9 +590,7 @@ func NewErrorContext() *ErrorContext { "// Process in batches instead of loading all at once", "GOGC=50 ./myapp // more aggressive garbage collection", }, - } - - return ec + }, } func (ec *ErrorContext) Enrich(err string) *EnrichedError { diff --git a/internal/engine/intelligence/capabilities.go b/internal/engine/intelligence/capabilities.go index 012a6ad12..d6a04a1e1 100644 --- a/internal/engine/intelligence/capabilities.go +++ b/internal/engine/intelligence/capabilities.go @@ -34,354 +34,361 @@ func NewCapabilityRegistry() *CapabilityRegistry { Categories: make(map[string][]string), } - builtins := []*Capability{ - { - ID: "code_write", - Name: "Write Code", - Description: "Write new code files", - Category: "Code", - Tools: []string{"file_write", "file_create"}, - Examples: []string{"Create a new Go HTTP handler", "Write a Python script to parse CSV"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "code_edit", - Name: "Edit Code", - Description: "Modify existing code", - Category: "Code", - Tools: []string{"file_edit", "file_write"}, - Examples: []string{"Add error handling to this function", "Rename variable across file"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "code_review", - Name: "Review Code", - Description: "Review code for issues", - Category: "Code", - Tools: []string{"file_read", "grep"}, - Examples: []string{"Review this PR for bugs", "Check for security issues"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "bug_fix", - Name: "Fix Bugs", - Description: "Debug and fix bugs", - Category: "Code", - Tools: []string{"file_read", "file_edit", "shell_exec", "grep"}, - Examples: []string{"Fix the nil pointer panic in handler.go", "Debug why tests are failing"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "test_write", - Name: "Write Tests", - Description: "Write unit/integration tests", - Category: "Testing", - Tools: []string{"file_write", "file_read", "shell_exec"}, - Examples: []string{"Write unit tests for the parser package", "Add integration test for API"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "refactor", - Name: "Refactor", - Description: "Restructure code", - Category: "Code", - Tools: []string{"file_read", "file_edit", "grep", "file_write"}, - Examples: []string{"Extract method from this function", "Split large file into modules"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "git_commit", - Name: "Git Commit", - Description: "Stage and commit changes", - Category: "Git", - Tools: []string{"shell_exec"}, - Examples: []string{"Commit these changes with a good message", "Stage and commit the fix"}, - Complexity: "simple", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "git_branch", - Name: "Git Branch", - Description: "Create/switch branches", - Category: "Git", - Tools: []string{"shell_exec"}, - Examples: []string{"Create a feature branch", "Switch to main branch"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "search_code", - Name: "Search Code", - Description: "Search codebase", - Category: "Navigation", - Tools: []string{"grep", "file_read"}, - Examples: []string{"Find all usages of UserService", "Search for TODO comments"}, - Complexity: "trivial", - Enabled: true, - }, - { - ID: "explain_code", - Name: "Explain Code", - Description: "Explain how code works", - Category: "Navigation", - Tools: []string{"file_read"}, - Examples: []string{"Explain this function", "How does the auth middleware work?"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "run_tests", - Name: "Run Tests", - Description: "Execute test suites", - Category: "Testing", - Tools: []string{"shell_exec"}, - Examples: []string{"Run all tests", "Run tests for the auth package"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "run_lint", - Name: "Run Linters", - Description: "Run linters", - Category: "Testing", - Tools: []string{"shell_exec"}, - Examples: []string{"Run golangci-lint", "Check for style violations"}, - Complexity: "trivial", - Enabled: true, - }, - { - ID: "file_create", - Name: "Create Files", - Description: "Create new files", - Category: "Files", - Tools: []string{"file_write", "file_create"}, - Examples: []string{"Create a new config file", "Add a Dockerfile"}, - Complexity: "trivial", - Enabled: true, - }, - { - ID: "file_delete", - Name: "Delete Files", - Description: "Delete files", - Category: "Files", - Tools: []string{"shell_exec"}, - Examples: []string{"Remove the old migration", "Delete unused test fixtures"}, - Complexity: "trivial", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "web_fetch", - Name: "Fetch Web Content", - Description: "Fetch web content", - Category: "External", - Tools: []string{"web_fetch"}, - Examples: []string{"Fetch the API docs from this URL", "Download the schema"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "shell_exec", - Name: "Shell Commands", - Description: "Run shell commands", - Category: "System", - Tools: []string{"shell_exec"}, - Examples: []string{"List files in the directory", "Check disk usage"}, - Complexity: "simple", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "project_scaffold", - Name: "Scaffold Project", - Description: "Generate project structure", - Category: "Code", - Tools: []string{"file_write", "file_create", "shell_exec"}, - Examples: []string{"Create a new Go module", "Scaffold a React app"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "dependency_manage", - Name: "Manage Dependencies", - Description: "Add/update dependencies", - Category: "System", - Tools: []string{"shell_exec", "file_edit"}, - Examples: []string{"Add the cobra library", "Update all dependencies"}, - Complexity: "moderate", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "doc_generate", - Name: "Generate Docs", - Description: "Generate documentation", - Category: "Documentation", - Tools: []string{"file_read", "file_write"}, - Examples: []string{"Generate API docs", "Write README for this package"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "config_manage", - Name: "Manage Config", - Description: "Manage configuration", - Category: "System", - Tools: []string{"file_read", "file_edit", "file_write"}, - Examples: []string{"Update the database config", "Add new environment variable"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "git_merge", - Name: "Git Merge", - Description: "Merge branches", - Category: "Git", - Tools: []string{"shell_exec"}, - Examples: []string{"Merge feature branch into main", "Resolve merge conflicts"}, - Complexity: "moderate", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "git_rebase", - Name: "Git Rebase", - Description: "Rebase branches", - Category: "Git", - Tools: []string{"shell_exec"}, - Examples: []string{"Rebase onto main", "Interactive rebase last 3 commits"}, - Complexity: "complex", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "git_stash", - Name: "Git Stash", - Description: "Stash/unstash changes", - Category: "Git", - Tools: []string{"shell_exec"}, - Examples: []string{"Stash current changes", "Apply last stash"}, - Complexity: "trivial", - Enabled: true, - }, - { - ID: "api_design", - Name: "Design API", - Description: "Design REST/gRPC APIs", - Category: "Architecture", - Tools: []string{"file_write", "file_read"}, - Examples: []string{"Design a REST API for user management", "Create OpenAPI spec"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "db_migration", - Name: "Database Migration", - Description: "Create database migrations", - Category: "Database", - Tools: []string{"file_write", "shell_exec"}, - Examples: []string{"Create migration for users table", "Add index on email column"}, - Complexity: "moderate", - RequiresApproval: true, - Enabled: true, - }, - { - ID: "db_query", - Name: "Database Query", - Description: "Write/optimize SQL queries", - Category: "Database", - Tools: []string{"file_read", "file_edit"}, - Examples: []string{"Optimize this slow query", "Write a join for user orders"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "error_handle", - Name: "Error Handling", - Description: "Add/improve error handling", - Category: "Code", - Tools: []string{"file_read", "file_edit"}, - Examples: []string{"Add proper error handling", "Wrap errors with context"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "perf_optimize", - Name: "Optimize Performance", - Description: "Profile and optimize performance", - Category: "Code", - Tools: []string{"file_read", "file_edit", "shell_exec"}, - Examples: []string{"Find performance bottleneck", "Optimize this hot loop"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "security_audit", - Name: "Security Audit", - Description: "Audit code for vulnerabilities", - Category: "Security", - Tools: []string{"file_read", "grep", "shell_exec"}, - Examples: []string{"Check for SQL injection", "Audit auth implementation"}, - Complexity: "complex", - Enabled: true, - }, - { - ID: "ci_configure", - Name: "Configure CI", - Description: "Set up CI/CD pipelines", - Category: "DevOps", - Tools: []string{"file_write", "file_read"}, - Examples: []string{"Create GitHub Actions workflow", "Add test stage to pipeline"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "docker_manage", - Name: "Docker Management", - Description: "Create/manage Dockerfiles and compose", - Category: "DevOps", - Tools: []string{"file_write", "file_edit", "shell_exec"}, - Examples: []string{"Create a multi-stage Dockerfile", "Set up docker-compose"}, - Complexity: "moderate", - Enabled: true, - }, - { - ID: "type_annotate", - Name: "Type Annotations", - Description: "Add/fix type annotations", - Category: "Code", - Tools: []string{"file_read", "file_edit"}, - Examples: []string{"Add TypeScript types", "Fix type errors"}, - Complexity: "simple", - Enabled: true, - }, - { - ID: "log_add", - Name: "Add Logging", - Description: "Add structured logging", - Category: "Code", - Tools: []string{"file_read", "file_edit"}, - Examples: []string{"Add logging to this service", "Improve log messages"}, - Complexity: "simple", - Enabled: true, - }, - } - - for _, cap := range builtins { - r.Capabilities[cap.ID] = cap + for i := range builtinCapabilities { + cap := builtinCapabilities[i] + // Deep-copy the slice fields so a registry cannot mutate the shared + // catalog through a capability it hands out. + cap.Tools = append([]string(nil), cap.Tools...) + cap.Examples = append([]string(nil), cap.Examples...) + r.Capabilities[cap.ID] = &cap r.Categories[cap.Category] = append(r.Categories[cap.Category], cap.ID) } return r } +// builtinCapabilities is the static capability catalog consulted by +// NewCapabilityRegistry. +var builtinCapabilities = []Capability{ + { + ID: "code_write", + Name: "Write Code", + Description: "Write new code files", + Category: "Code", + Tools: []string{"file_write", "file_create"}, + Examples: []string{"Create a new Go HTTP handler", "Write a Python script to parse CSV"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "code_edit", + Name: "Edit Code", + Description: "Modify existing code", + Category: "Code", + Tools: []string{"file_edit", "file_write"}, + Examples: []string{"Add error handling to this function", "Rename variable across file"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "code_review", + Name: "Review Code", + Description: "Review code for issues", + Category: "Code", + Tools: []string{"file_read", "grep"}, + Examples: []string{"Review this PR for bugs", "Check for security issues"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "bug_fix", + Name: "Fix Bugs", + Description: "Debug and fix bugs", + Category: "Code", + Tools: []string{"file_read", "file_edit", "shell_exec", "grep"}, + Examples: []string{"Fix the nil pointer panic in handler.go", "Debug why tests are failing"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "test_write", + Name: "Write Tests", + Description: "Write unit/integration tests", + Category: "Testing", + Tools: []string{"file_write", "file_read", "shell_exec"}, + Examples: []string{"Write unit tests for the parser package", "Add integration test for API"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "refactor", + Name: "Refactor", + Description: "Restructure code", + Category: "Code", + Tools: []string{"file_read", "file_edit", "grep", "file_write"}, + Examples: []string{"Extract method from this function", "Split large file into modules"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "git_commit", + Name: "Git Commit", + Description: "Stage and commit changes", + Category: "Git", + Tools: []string{"shell_exec"}, + Examples: []string{"Commit these changes with a good message", "Stage and commit the fix"}, + Complexity: "simple", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "git_branch", + Name: "Git Branch", + Description: "Create/switch branches", + Category: "Git", + Tools: []string{"shell_exec"}, + Examples: []string{"Create a feature branch", "Switch to main branch"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "search_code", + Name: "Search Code", + Description: "Search codebase", + Category: "Navigation", + Tools: []string{"grep", "file_read"}, + Examples: []string{"Find all usages of UserService", "Search for TODO comments"}, + Complexity: "trivial", + Enabled: true, + }, + { + ID: "explain_code", + Name: "Explain Code", + Description: "Explain how code works", + Category: "Navigation", + Tools: []string{"file_read"}, + Examples: []string{"Explain this function", "How does the auth middleware work?"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "run_tests", + Name: "Run Tests", + Description: "Execute test suites", + Category: "Testing", + Tools: []string{"shell_exec"}, + Examples: []string{"Run all tests", "Run tests for the auth package"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "run_lint", + Name: "Run Linters", + Description: "Run linters", + Category: "Testing", + Tools: []string{"shell_exec"}, + Examples: []string{"Run golangci-lint", "Check for style violations"}, + Complexity: "trivial", + Enabled: true, + }, + { + ID: "file_create", + Name: "Create Files", + Description: "Create new files", + Category: "Files", + Tools: []string{"file_write", "file_create"}, + Examples: []string{"Create a new config file", "Add a Dockerfile"}, + Complexity: "trivial", + Enabled: true, + }, + { + ID: "file_delete", + Name: "Delete Files", + Description: "Delete files", + Category: "Files", + Tools: []string{"shell_exec"}, + Examples: []string{"Remove the old migration", "Delete unused test fixtures"}, + Complexity: "trivial", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "web_fetch", + Name: "Fetch Web Content", + Description: "Fetch web content", + Category: "External", + Tools: []string{"web_fetch"}, + Examples: []string{"Fetch the API docs from this URL", "Download the schema"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "shell_exec", + Name: "Shell Commands", + Description: "Run shell commands", + Category: "System", + Tools: []string{"shell_exec"}, + Examples: []string{"List files in the directory", "Check disk usage"}, + Complexity: "simple", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "project_scaffold", + Name: "Scaffold Project", + Description: "Generate project structure", + Category: "Code", + Tools: []string{"file_write", "file_create", "shell_exec"}, + Examples: []string{"Create a new Go module", "Scaffold a React app"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "dependency_manage", + Name: "Manage Dependencies", + Description: "Add/update dependencies", + Category: "System", + Tools: []string{"shell_exec", "file_edit"}, + Examples: []string{"Add the cobra library", "Update all dependencies"}, + Complexity: "moderate", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "doc_generate", + Name: "Generate Docs", + Description: "Generate documentation", + Category: "Documentation", + Tools: []string{"file_read", "file_write"}, + Examples: []string{"Generate API docs", "Write README for this package"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "config_manage", + Name: "Manage Config", + Description: "Manage configuration", + Category: "System", + Tools: []string{"file_read", "file_edit", "file_write"}, + Examples: []string{"Update the database config", "Add new environment variable"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "git_merge", + Name: "Git Merge", + Description: "Merge branches", + Category: "Git", + Tools: []string{"shell_exec"}, + Examples: []string{"Merge feature branch into main", "Resolve merge conflicts"}, + Complexity: "moderate", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "git_rebase", + Name: "Git Rebase", + Description: "Rebase branches", + Category: "Git", + Tools: []string{"shell_exec"}, + Examples: []string{"Rebase onto main", "Interactive rebase last 3 commits"}, + Complexity: "complex", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "git_stash", + Name: "Git Stash", + Description: "Stash/unstash changes", + Category: "Git", + Tools: []string{"shell_exec"}, + Examples: []string{"Stash current changes", "Apply last stash"}, + Complexity: "trivial", + Enabled: true, + }, + { + ID: "api_design", + Name: "Design API", + Description: "Design REST/gRPC APIs", + Category: "Architecture", + Tools: []string{"file_write", "file_read"}, + Examples: []string{"Design a REST API for user management", "Create OpenAPI spec"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "db_migration", + Name: "Database Migration", + Description: "Create database migrations", + Category: "Database", + Tools: []string{"file_write", "shell_exec"}, + Examples: []string{"Create migration for users table", "Add index on email column"}, + Complexity: "moderate", + RequiresApproval: true, + Enabled: true, + }, + { + ID: "db_query", + Name: "Database Query", + Description: "Write/optimize SQL queries", + Category: "Database", + Tools: []string{"file_read", "file_edit"}, + Examples: []string{"Optimize this slow query", "Write a join for user orders"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "error_handle", + Name: "Error Handling", + Description: "Add/improve error handling", + Category: "Code", + Tools: []string{"file_read", "file_edit"}, + Examples: []string{"Add proper error handling", "Wrap errors with context"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "perf_optimize", + Name: "Optimize Performance", + Description: "Profile and optimize performance", + Category: "Code", + Tools: []string{"file_read", "file_edit", "shell_exec"}, + Examples: []string{"Find performance bottleneck", "Optimize this hot loop"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "security_audit", + Name: "Security Audit", + Description: "Audit code for vulnerabilities", + Category: "Security", + Tools: []string{"file_read", "grep", "shell_exec"}, + Examples: []string{"Check for SQL injection", "Audit auth implementation"}, + Complexity: "complex", + Enabled: true, + }, + { + ID: "ci_configure", + Name: "Configure CI", + Description: "Set up CI/CD pipelines", + Category: "DevOps", + Tools: []string{"file_write", "file_read"}, + Examples: []string{"Create GitHub Actions workflow", "Add test stage to pipeline"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "docker_manage", + Name: "Docker Management", + Description: "Create/manage Dockerfiles and compose", + Category: "DevOps", + Tools: []string{"file_write", "file_edit", "shell_exec"}, + Examples: []string{"Create a multi-stage Dockerfile", "Set up docker-compose"}, + Complexity: "moderate", + Enabled: true, + }, + { + ID: "type_annotate", + Name: "Type Annotations", + Description: "Add/fix type annotations", + Category: "Code", + Tools: []string{"file_read", "file_edit"}, + Examples: []string{"Add TypeScript types", "Fix type errors"}, + Complexity: "simple", + Enabled: true, + }, + { + ID: "log_add", + Name: "Add Logging", + Description: "Add structured logging", + Category: "Code", + Tools: []string{"file_read", "file_edit"}, + Examples: []string{"Add logging to this service", "Improve log messages"}, + Complexity: "simple", + Enabled: true, + }, +} + // GetCapability returns a capability by ID, or nil if not found. func (r *CapabilityRegistry) GetCapability(id string) *Capability { r.mu.RLock() diff --git a/internal/engine/observability/debug_recorder_test.go b/internal/engine/observability/debug_recorder_test.go index 9e0fc490c..9a9ba2b07 100644 --- a/internal/engine/observability/debug_recorder_test.go +++ b/internal/engine/observability/debug_recorder_test.go @@ -94,8 +94,10 @@ func TestRecordStep(t *testing.T) { func TestRecordStepNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic when no active session dr.RecordStep("read", "foo.go", "result", "insight") + if dr.ActiveSession != nil { + t.Error("expected no active session to be created") + } } func TestRecordStepDeduplicatesFiles(t *testing.T) { @@ -140,8 +142,10 @@ func TestAddHypothesis(t *testing.T) { func TestAddHypothesisNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic dr.AddHypothesis("some hypothesis") + if dr.ActiveSession != nil { + t.Error("expected no active session to be created") + } } func TestConfirmHypothesis(t *testing.T) { @@ -168,9 +172,12 @@ func TestConfirmHypothesisOutOfBounds(t *testing.T) { dr.StartSession("test") dr.AddHypothesis("h1") - // Should not panic dr.ConfirmHypothesis(-1, "evidence") dr.ConfirmHypothesis(5, "evidence") + h := dr.ActiveSession.HypothesesTested[0] + if h.Tested || h.Confirmed { + t.Error("out-of-bounds confirm must not mutate the hypothesis") + } } func TestRejectHypothesis(t *testing.T) { @@ -194,8 +201,10 @@ func TestRejectHypothesis(t *testing.T) { func TestRejectHypothesisNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic dr.RejectHypothesis(0, "evidence") + if dr.ActiveSession != nil { + t.Error("expected no active session to be created") + } } func TestSetRootCause(t *testing.T) { @@ -211,8 +220,10 @@ func TestSetRootCause(t *testing.T) { func TestSetRootCauseNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic dr.SetRootCause("some cause") + if dr.ActiveSession != nil { + t.Error("expected no active session to be created") + } } func TestSetResolution(t *testing.T) { @@ -228,8 +239,10 @@ func TestSetResolution(t *testing.T) { func TestSetResolutionNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic dr.SetResolution("some resolution") + if dr.ActiveSession != nil { + t.Error("expected no active session to be created") + } } func TestEndSession(t *testing.T) { @@ -265,8 +278,13 @@ func TestEndSessionUnsuccessful(t *testing.T) { func TestEndSessionNoActiveSession(t *testing.T) { dr := NewDebugRecorder("/tmp/test-debug") - // Should not panic dr.EndSession(true) + if dr.ActiveSession != nil { + t.Error("expected no active session") + } + if len(dr.Sessions) != 0 { + t.Errorf("expected no sessions recorded, got %d", len(dr.Sessions)) + } } func TestFormatSession(t *testing.T) { diff --git a/internal/intelligence/repomap/migration_detector.go b/internal/intelligence/repomap/migration_detector.go index 5a4c65ba1..3bac37196 100644 --- a/internal/intelligence/repomap/migration_detector.go +++ b/internal/intelligence/repomap/migration_detector.go @@ -53,385 +53,392 @@ func NewMigrationDetector() *MigrationDetector { return md } +// builtinRules returns the static migration-rule catalog. A fresh slice is +// returned so callers can filter or annotate it without mutating the catalog. func builtinRules() []MigrationRule { - return []MigrationRule{ - // Go: ioutil deprecations (Go 1.16+) - { - ID: "go-ioutil-readfile", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.ReadFile`), - NewPattern: "os.ReadFile", - Reason: "ioutil.ReadFile deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-writefile", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.WriteFile`), - NewPattern: "os.WriteFile", - Reason: "ioutil.WriteFile deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-tempdir", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.TempDir`), - NewPattern: "os.MkdirTemp", - Reason: "ioutil.TempDir deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-readall", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.ReadAll`), - NewPattern: "io.ReadAll", - Reason: "ioutil.ReadAll deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-tempfile", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.TempFile`), - NewPattern: "os.CreateTemp", - Reason: "ioutil.TempFile deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-readdir", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.ReadDir`), - NewPattern: "os.ReadDir", - Reason: "ioutil.ReadDir deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-nopclose", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.NopCloser`), - NewPattern: "io.NopCloser", - Reason: "ioutil.NopCloser deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - { - ID: "go-ioutil-discard", - Language: "go", - OldPattern: regexp.MustCompile(`ioutil\.Discard`), - NewPattern: "io.Discard", - Reason: "ioutil.Discard deprecated since Go 1.16", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Go 1.16", - }, - // Go: interface{} -> any (Go 1.18+) - { - ID: "go-interface-any", - Language: "go", - OldPattern: regexp.MustCompile(`interface\{\}`), - NewPattern: "any", - Reason: "interface{} can be replaced with any (Go 1.18+)", - Priority: "medium", - AutoFixable: true, - Category: "idiom", - Since: "Go 1.18", - }, - // Go: sort.Slice -> slices.Sort (Go 1.21+) - { - ID: "go-sort-slice", - Language: "go", - OldPattern: regexp.MustCompile(`sort\.Slice\(`), - NewPattern: "slices.Sort(", - Reason: "consider slices.Sort for type-safe sorting (Go 1.21+)", - Priority: "medium", - AutoFixable: false, - Category: "idiom", - Since: "Go 1.21", - }, - // Go: sync.Mutex in struct without pointer (potential copy) - { - ID: "go-mutex-value", - Language: "go", - OldPattern: regexp.MustCompile(`mu\s+sync\.Mutex`), - NewPattern: "mu sync.Mutex (ensure struct is not copied)", - Reason: "sync.Mutex in struct without pointer may cause copy issues", - Priority: "medium", - AutoFixable: false, - Category: "security", - Since: "", - }, - // Go: strings.Title deprecated (Go 1.18+) - { - ID: "go-strings-title", - Language: "go", - OldPattern: regexp.MustCompile(`strings\.Title\(`), - NewPattern: "cases.Title(language.English).String(", - Reason: "strings.Title deprecated since Go 1.18", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Go 1.18", - }, - // Go: io/ioutil import - { - ID: "go-import-ioutil", - Language: "go", - OldPattern: regexp.MustCompile(`"io/ioutil"`), - NewPattern: `remove "io/ioutil" import`, - Reason: "io/ioutil package deprecated since Go 1.16", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Go 1.16", - }, - // Go: errors.New + fmt.Sprintf -> fmt.Errorf - { - ID: "go-errors-sprintf", - Language: "go", - OldPattern: regexp.MustCompile(`errors\.New\(fmt\.Sprintf\(`), - NewPattern: "fmt.Errorf(", - Reason: "use fmt.Errorf instead of errors.New(fmt.Sprintf(...))", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "", - }, - // Go: context.Background in tests -> context.TODO or test-specific - { - ID: "go-http-handle-deprecated", - Language: "go", - OldPattern: regexp.MustCompile(`http\.HandleFunc\(`), - NewPattern: "http.NewServeMux().HandleFunc(", - Reason: "avoid DefaultServeMux for better isolation", - Priority: "low", - AutoFixable: false, - Category: "security", - Since: "", - }, - // Go: rand.Seed deprecated (Go 1.20+) - { - ID: "go-rand-seed", - Language: "go", - OldPattern: regexp.MustCompile(`rand\.Seed\(`), - NewPattern: "remove rand.Seed (automatic since Go 1.20)", - Reason: "rand.Seed deprecated since Go 1.20", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Go 1.20", - }, - - // Python rules - { - ID: "py-os-path-join", - Language: "python", - OldPattern: regexp.MustCompile(`os\.path\.join\(`), - NewPattern: "pathlib.Path(...) / ...", - Reason: "prefer pathlib.Path for modern path handling", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "Python 3.4", - }, - { - ID: "py-format-string", - Language: "python", - OldPattern: regexp.MustCompile(`"[^"]*"\s*\.format\(`), - NewPattern: "f-string", - Reason: "f-strings are more readable and performant", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "Python 3.6", - }, - { - ID: "py-format-string-single", - Language: "python", - OldPattern: regexp.MustCompile(`'[^']*'\s*\.format\(`), - NewPattern: "f-string", - Reason: "f-strings are more readable and performant", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "Python 3.6", - }, - { - ID: "py-dict-has-key", - Language: "python", - OldPattern: regexp.MustCompile(`\.has_key\(`), - NewPattern: "key in dict", - Reason: "dict.has_key() removed in Python 3", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Python 3.0", - }, - { - ID: "py-print-statement", - Language: "python", - OldPattern: regexp.MustCompile(`(?m)^print\s+[^(]`), - NewPattern: "print(...)", - Reason: "print statement removed in Python 3", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Python 3.0", - }, - { - ID: "py-urllib2", - Language: "python", - OldPattern: regexp.MustCompile(`import\s+urllib2`), - NewPattern: "import urllib.request", - Reason: "urllib2 removed in Python 3", - Priority: "high", - AutoFixable: false, - Category: "deprecated", - Since: "Python 3.0", - }, - { - ID: "py-raw-input", - Language: "python", - OldPattern: regexp.MustCompile(`raw_input\(`), - NewPattern: "input(", - Reason: "raw_input renamed to input in Python 3", - Priority: "high", - AutoFixable: true, - Category: "deprecated", - Since: "Python 3.0", - }, - { - ID: "py-type-comment", - Language: "python", - OldPattern: regexp.MustCompile(`#\s*type:\s*\(`), - NewPattern: "native type annotations", - Reason: "type comments superseded by native annotations", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "Python 3.5", - }, - { - ID: "py-typing-optional", - Language: "python", - OldPattern: regexp.MustCompile(`typing\.Optional\[`), - NewPattern: "X | None", - Reason: "use X | None syntax (Python 3.10+)", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "Python 3.10", - }, - - // JavaScript/TypeScript rules - { - ID: "js-var-usage", - Language: "javascript", - OldPattern: regexp.MustCompile(`(?m)^\s*var\s+`), - NewPattern: "const or let", - Reason: "var has function-scope issues; prefer const/let", - Priority: "medium", - AutoFixable: false, - Category: "idiom", - Since: "ES6", - }, - { - ID: "js-require", - Language: "javascript", - OldPattern: regexp.MustCompile(`(?m)^(const|let|var)\s+\w+\s*=\s*require\(`), - NewPattern: "import ... from '...'", - Reason: "prefer ES modules import over CommonJS require", - Priority: "medium", - AutoFixable: false, - Category: "idiom", - Since: "ES6", - }, - { - ID: "js-then-catch", - Language: "javascript", - OldPattern: regexp.MustCompile(`\.then\([^)]*\)\s*\.catch\(`), - NewPattern: "async/await with try/catch", - Reason: "async/await is more readable than .then().catch()", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "ES2017", - }, - { - ID: "js-moment", - Language: "javascript", - OldPattern: regexp.MustCompile(`require\(['"]moment['"]\)|from\s+['"]moment['"]`), - NewPattern: "dayjs or native Intl", - Reason: "moment.js is in maintenance mode; use dayjs or Intl", - Priority: "medium", - AutoFixable: false, - Category: "deprecated", - Since: "2020", - }, - { - ID: "js-callback-hell", - Language: "javascript", - OldPattern: regexp.MustCompile(`function\s*\([^)]*err[^)]*\)\s*\{`), - NewPattern: "async/await or Promises", - Reason: "callback patterns can be replaced with async/await", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "ES2017", - }, - { - ID: "ts-any-type", - Language: "typescript", - OldPattern: regexp.MustCompile(`:\s*any\b`), - NewPattern: "specific type or unknown", - Reason: "avoid any; use unknown or a specific type for type safety", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "", - }, - { - ID: "js-substr", - Language: "javascript", - OldPattern: regexp.MustCompile(`\.substr\(`), - NewPattern: ".slice(", - Reason: "String.prototype.substr is deprecated", - Priority: "medium", - AutoFixable: true, - Category: "deprecated", - Since: "ES2022", - }, - { - ID: "js-arguments-object", - Language: "javascript", - OldPattern: regexp.MustCompile(`\barguments\[`), - NewPattern: "...rest parameters", - Reason: "use rest parameters instead of arguments object", - Priority: "low", - AutoFixable: false, - Category: "idiom", - Since: "ES6", - }, - } + rules := make([]MigrationRule, len(builtinMigrationRules)) + copy(rules, builtinMigrationRules) + return rules +} + +// builtinMigrationRules is the static migration-rule catalog. +var builtinMigrationRules = []MigrationRule{ + // Go: ioutil deprecations (Go 1.16+) + { + ID: "go-ioutil-readfile", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.ReadFile`), + NewPattern: "os.ReadFile", + Reason: "ioutil.ReadFile deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-writefile", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.WriteFile`), + NewPattern: "os.WriteFile", + Reason: "ioutil.WriteFile deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-tempdir", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.TempDir`), + NewPattern: "os.MkdirTemp", + Reason: "ioutil.TempDir deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-readall", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.ReadAll`), + NewPattern: "io.ReadAll", + Reason: "ioutil.ReadAll deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-tempfile", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.TempFile`), + NewPattern: "os.CreateTemp", + Reason: "ioutil.TempFile deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-readdir", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.ReadDir`), + NewPattern: "os.ReadDir", + Reason: "ioutil.ReadDir deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-nopclose", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.NopCloser`), + NewPattern: "io.NopCloser", + Reason: "ioutil.NopCloser deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + { + ID: "go-ioutil-discard", + Language: "go", + OldPattern: regexp.MustCompile(`ioutil\.Discard`), + NewPattern: "io.Discard", + Reason: "ioutil.Discard deprecated since Go 1.16", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Go 1.16", + }, + // Go: interface{} -> any (Go 1.18+) + { + ID: "go-interface-any", + Language: "go", + OldPattern: regexp.MustCompile(`interface\{\}`), + NewPattern: "any", + Reason: "interface{} can be replaced with any (Go 1.18+)", + Priority: "medium", + AutoFixable: true, + Category: "idiom", + Since: "Go 1.18", + }, + // Go: sort.Slice -> slices.Sort (Go 1.21+) + { + ID: "go-sort-slice", + Language: "go", + OldPattern: regexp.MustCompile(`sort\.Slice\(`), + NewPattern: "slices.Sort(", + Reason: "consider slices.Sort for type-safe sorting (Go 1.21+)", + Priority: "medium", + AutoFixable: false, + Category: "idiom", + Since: "Go 1.21", + }, + // Go: sync.Mutex in struct without pointer (potential copy) + { + ID: "go-mutex-value", + Language: "go", + OldPattern: regexp.MustCompile(`mu\s+sync\.Mutex`), + NewPattern: "mu sync.Mutex (ensure struct is not copied)", + Reason: "sync.Mutex in struct without pointer may cause copy issues", + Priority: "medium", + AutoFixable: false, + Category: "security", + Since: "", + }, + // Go: strings.Title deprecated (Go 1.18+) + { + ID: "go-strings-title", + Language: "go", + OldPattern: regexp.MustCompile(`strings\.Title\(`), + NewPattern: "cases.Title(language.English).String(", + Reason: "strings.Title deprecated since Go 1.18", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Go 1.18", + }, + // Go: io/ioutil import + { + ID: "go-import-ioutil", + Language: "go", + OldPattern: regexp.MustCompile(`"io/ioutil"`), + NewPattern: `remove "io/ioutil" import`, + Reason: "io/ioutil package deprecated since Go 1.16", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Go 1.16", + }, + // Go: errors.New + fmt.Sprintf -> fmt.Errorf + { + ID: "go-errors-sprintf", + Language: "go", + OldPattern: regexp.MustCompile(`errors\.New\(fmt\.Sprintf\(`), + NewPattern: "fmt.Errorf(", + Reason: "use fmt.Errorf instead of errors.New(fmt.Sprintf(...))", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "", + }, + // Go: context.Background in tests -> context.TODO or test-specific + { + ID: "go-http-handle-deprecated", + Language: "go", + OldPattern: regexp.MustCompile(`http\.HandleFunc\(`), + NewPattern: "http.NewServeMux().HandleFunc(", + Reason: "avoid DefaultServeMux for better isolation", + Priority: "low", + AutoFixable: false, + Category: "security", + Since: "", + }, + // Go: rand.Seed deprecated (Go 1.20+) + { + ID: "go-rand-seed", + Language: "go", + OldPattern: regexp.MustCompile(`rand\.Seed\(`), + NewPattern: "remove rand.Seed (automatic since Go 1.20)", + Reason: "rand.Seed deprecated since Go 1.20", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Go 1.20", + }, + + // Python rules + { + ID: "py-os-path-join", + Language: "python", + OldPattern: regexp.MustCompile(`os\.path\.join\(`), + NewPattern: "pathlib.Path(...) / ...", + Reason: "prefer pathlib.Path for modern path handling", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "Python 3.4", + }, + { + ID: "py-format-string", + Language: "python", + OldPattern: regexp.MustCompile(`"[^"]*"\s*\.format\(`), + NewPattern: "f-string", + Reason: "f-strings are more readable and performant", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "Python 3.6", + }, + { + ID: "py-format-string-single", + Language: "python", + OldPattern: regexp.MustCompile(`'[^']*'\s*\.format\(`), + NewPattern: "f-string", + Reason: "f-strings are more readable and performant", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "Python 3.6", + }, + { + ID: "py-dict-has-key", + Language: "python", + OldPattern: regexp.MustCompile(`\.has_key\(`), + NewPattern: "key in dict", + Reason: "dict.has_key() removed in Python 3", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Python 3.0", + }, + { + ID: "py-print-statement", + Language: "python", + OldPattern: regexp.MustCompile(`(?m)^print\s+[^(]`), + NewPattern: "print(...)", + Reason: "print statement removed in Python 3", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Python 3.0", + }, + { + ID: "py-urllib2", + Language: "python", + OldPattern: regexp.MustCompile(`import\s+urllib2`), + NewPattern: "import urllib.request", + Reason: "urllib2 removed in Python 3", + Priority: "high", + AutoFixable: false, + Category: "deprecated", + Since: "Python 3.0", + }, + { + ID: "py-raw-input", + Language: "python", + OldPattern: regexp.MustCompile(`raw_input\(`), + NewPattern: "input(", + Reason: "raw_input renamed to input in Python 3", + Priority: "high", + AutoFixable: true, + Category: "deprecated", + Since: "Python 3.0", + }, + { + ID: "py-type-comment", + Language: "python", + OldPattern: regexp.MustCompile(`#\s*type:\s*\(`), + NewPattern: "native type annotations", + Reason: "type comments superseded by native annotations", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "Python 3.5", + }, + { + ID: "py-typing-optional", + Language: "python", + OldPattern: regexp.MustCompile(`typing\.Optional\[`), + NewPattern: "X | None", + Reason: "use X | None syntax (Python 3.10+)", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "Python 3.10", + }, + + // JavaScript/TypeScript rules + { + ID: "js-var-usage", + Language: "javascript", + OldPattern: regexp.MustCompile(`(?m)^\s*var\s+`), + NewPattern: "const or let", + Reason: "var has function-scope issues; prefer const/let", + Priority: "medium", + AutoFixable: false, + Category: "idiom", + Since: "ES6", + }, + { + ID: "js-require", + Language: "javascript", + OldPattern: regexp.MustCompile(`(?m)^(const|let|var)\s+\w+\s*=\s*require\(`), + NewPattern: "import ... from '...'", + Reason: "prefer ES modules import over CommonJS require", + Priority: "medium", + AutoFixable: false, + Category: "idiom", + Since: "ES6", + }, + { + ID: "js-then-catch", + Language: "javascript", + OldPattern: regexp.MustCompile(`\.then\([^)]*\)\s*\.catch\(`), + NewPattern: "async/await with try/catch", + Reason: "async/await is more readable than .then().catch()", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "ES2017", + }, + { + ID: "js-moment", + Language: "javascript", + OldPattern: regexp.MustCompile(`require\(['"]moment['"]\)|from\s+['"]moment['"]`), + NewPattern: "dayjs or native Intl", + Reason: "moment.js is in maintenance mode; use dayjs or Intl", + Priority: "medium", + AutoFixable: false, + Category: "deprecated", + Since: "2020", + }, + { + ID: "js-callback-hell", + Language: "javascript", + OldPattern: regexp.MustCompile(`function\s*\([^)]*err[^)]*\)\s*\{`), + NewPattern: "async/await or Promises", + Reason: "callback patterns can be replaced with async/await", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "ES2017", + }, + { + ID: "ts-any-type", + Language: "typescript", + OldPattern: regexp.MustCompile(`:\s*any\b`), + NewPattern: "specific type or unknown", + Reason: "avoid any; use unknown or a specific type for type safety", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "", + }, + { + ID: "js-substr", + Language: "javascript", + OldPattern: regexp.MustCompile(`\.substr\(`), + NewPattern: ".slice(", + Reason: "String.prototype.substr is deprecated", + Priority: "medium", + AutoFixable: true, + Category: "deprecated", + Since: "ES2022", + }, + { + ID: "js-arguments-object", + Language: "javascript", + OldPattern: regexp.MustCompile(`\barguments\[`), + NewPattern: "...rest parameters", + Reason: "use rest parameters instead of arguments object", + Priority: "low", + AutoFixable: false, + Category: "idiom", + Since: "ES6", + }, } // languageForFile returns the language identifier based on file extension. diff --git a/internal/testaudit/assertion_audit_test.go b/internal/testaudit/assertion_audit_test.go new file mode 100644 index 000000000..7f0814718 --- /dev/null +++ b/internal/testaudit/assertion_audit_test.go @@ -0,0 +1,98 @@ +package testaudit + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// zeroAssertionBaseline is the number of Test* functions without an assertion +// as of this writing. The guard fails when the count grows, so new tests cannot +// silently add to the debt; reduce the baseline as tests are fixed. +const zeroAssertionBaseline = 229 + +// assertionCall reports whether an expression is a test assertion +// (t.Error/Fatal/Fail/FailNow, require.*, assert.*, or a helper that calls +// t.Helper). +func assertionCall(fn *ast.FuncDecl) bool { + found := false + ast.Inspect(fn, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + name := sel.Sel.Name + switch name { + case "Error", "Errorf", "Fatal", "Fatalf", "Fail", "FailNow": + if id, ok := sel.X.(*ast.Ident); ok && id.Name == "t" { + found = true + return false + } + case "Equal", "NotEqual", "True", "False", "Nil", "NotNil", "NoError", + "ErrorIs", "Contains", "Len", "Empty": + if id, ok := sel.X.(*ast.Ident); ok && (id.Name == "require" || id.Name == "assert") { + found = true + return false + } + } + return true + }) + return found +} + +// TestNoZeroAssertionTests fails when the number of assertion-free Test* +// functions grows beyond the baseline. These tests pass regardless of behavior, +// giving false confidence. +func TestNoZeroAssertionTests(t *testing.T) { + root := repoRoot(t) + var count int + var examples []string + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + base := d.Name() + if base == ".git" || base == "node_modules" || base == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return nil + } + rel, _ := filepath.Rel(root, path) + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || !strings.HasPrefix(fn.Name.Name, "Test") { + continue + } + if !assertionCall(fn) { + count++ + if len(examples) < 20 { + examples = append(examples, rel+":"+fn.Name.Name) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + + if count > zeroAssertionBaseline { + t.Errorf("zero-assertion test functions grew to %d (baseline %d); add assertions or update the baseline deliberately:\n%s", + count, zeroAssertionBaseline, strings.Join(examples, "\n")) + } + t.Logf("zero-assertion test functions: %d (baseline %d)", count, zeroAssertionBaseline) +} diff --git a/internal/testutil/mock_llm.go b/internal/testutil/mock_llm.go deleted file mode 100644 index ef85ef9f8..000000000 --- a/internal/testutil/mock_llm.go +++ /dev/null @@ -1,174 +0,0 @@ -package testutil - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" -) - -// MockLLMServer provides a configurable mock LLM API server for testing. -type MockLLMServer struct { - Server *httptest.Server - Responses []MockResponse - Requests []MockRequest - mu sync.Mutex - idx int -} - -// MockResponse defines a canned response from the mock LLM. -type MockResponse struct { - Content string - ToolUse []ToolUseBlock - StopReason string - StatusCode int -} - -// ToolUseBlock represents a tool call in the response. -type ToolUseBlock struct { - ID string - Name string - Input map[string]interface{} -} - -// MockRequest records a request made to the mock server. -type MockRequest struct { - Method string - Path string - Body map[string]interface{} -} - -// NewMockLLMServer creates a mock server that returns canned responses in order. -func NewMockLLMServer(t *testing.T, responses ...MockResponse) *MockLLMServer { - t.Helper() - m := &MockLLMServer{Responses: responses} - m.Server = NewLoopbackHTTPServer(t, http.HandlerFunc(m.handler)) - t.Cleanup(m.Server.Close) - return m -} - -func (m *MockLLMServer) handler(w http.ResponseWriter, r *http.Request) { - m.mu.Lock() - defer m.mu.Unlock() - - var body map[string]interface{} - if r.Body != nil { - _ = json.NewDecoder(r.Body).Decode(&body) - } - m.Requests = append(m.Requests, MockRequest{ - Method: r.Method, - Path: r.URL.Path, - Body: body, - }) - - if m.idx >= len(m.Responses) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprintf(w, `{"error":"no more mock responses"}`) - return - } - - resp := m.Responses[m.idx] - m.idx++ - - if resp.StatusCode != 0 && resp.StatusCode != 200 { - w.WriteHeader(resp.StatusCode) - _, _ = fmt.Fprintf(w, `{"error":{"type":"error","message":"mock error"}}`) - return - } - - w.Header().Set("Content-Type", "application/json") - - // Build Anthropic-style response - content := []map[string]interface{}{} - if resp.Content != "" { - content = append(content, map[string]interface{}{ - "type": "text", - "text": resp.Content, - }) - } - for _, tu := range resp.ToolUse { - content = append(content, map[string]interface{}{ - "type": "tool_use", - "id": tu.ID, - "name": tu.Name, - "input": tu.Input, - }) - } - - stopReason := resp.StopReason - if stopReason == "" { - stopReason = "end_turn" - } - - result := map[string]interface{}{ - "id": fmt.Sprintf("msg_%d", m.idx), - "type": "message", - "role": "assistant", - "content": content, - "model": "mock-model", - "stop_reason": stopReason, - "usage": map[string]int{"input_tokens": 100, "output_tokens": 50}, - } - - _ = json.NewEncoder(w).Encode(result) -} - -// URL returns the base URL of the mock server. -func (m *MockLLMServer) URL() string { - return m.Server.URL -} - -// RequestCount returns how many requests were made. -func (m *MockLLMServer) RequestCount() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.Requests) -} - -// LastRequest returns the most recent request body. -func (m *MockLLMServer) LastRequest() MockRequest { - m.mu.Lock() - defer m.mu.Unlock() - if len(m.Requests) == 0 { - return MockRequest{} - } - return m.Requests[len(m.Requests)-1] -} - -// SimpleTextResponse creates a mock response with just text content. -func SimpleTextResponse(text string) MockResponse { - return MockResponse{Content: text} -} - -// ToolUseResponse creates a mock response that calls a tool. -func ToolUseResponse(toolName string, input map[string]interface{}) MockResponse { - return MockResponse{ - ToolUse: []ToolUseBlock{{ - ID: "toolu_mock_1", - Name: toolName, - Input: input, - }}, - StopReason: "tool_use", - } -} - -// ErrorResponse creates a mock error response. -func ErrorResponse(statusCode int) MockResponse { - return MockResponse{StatusCode: statusCode} -} - -// ContainsString checks if any request body contains the given string. -func (m *MockLLMServer) ContainsString(s string) bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, req := range m.Requests { - data, _ := json.Marshal(req.Body) - if strings.Contains(string(data), s) { - return true - } - } - return false -}