From ee9f2b259b7a1da87add04ac5da154bffdaecb80 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Mar 2026 16:10:57 +0100 Subject: [PATCH] Add get_architecture, manage_adr tools; rename Leiden to Louvain New tools: - get_architecture: codebase architecture overview with 12 selectable aspects (languages, packages, entry_points, routes, hotspots, boundaries, services, layers, clusters, file_tree, adr) - manage_adr: CRUD for Architecture Decision Records with 6 fixed sections, section filtering, validation, and discovery of existing architecture docs Architecture analysis fixes: - Fix qnToPackage extracting segment[2] for meaningful sub-packages instead of segment[1] which collapsed everything to top-level dirs - Filter test functions from entry_points, hotspots, and routes using both is_test property (Module/File nodes) and file_path pattern matching - Filter boundaries to Function/Method/Class nodes only - Add FindArchitectureDocs for discovering existing architecture documentation Naming corrections: - Rename Leiden to Louvain throughout (algorithm is simplified Louvain, not actual Leiden which requires CPM-based refinement) - leiden.go -> louvain.go with all symbols renamed - Remove unused ClusterInfo.Modularity field (always zero) Other changes: - Case-insensitive search by default for search_graph and search_code - Remove read_file and list_directory tools (handled by coding agents natively) - Update tool descriptions and README for 8000 char ADR limit --- README.md | 92 ++- internal/pipeline/communities.go | 4 +- internal/store/architecture.go | 1126 +++++++++++++++++++++++++++ internal/store/architecture_test.go | 1048 +++++++++++++++++++++++++ internal/store/louvain.go | 224 ++++++ internal/store/search.go | 43 +- internal/store/store.go | 10 + internal/tools/architecture.go | 326 ++++++++ internal/tools/code_search.go | 49 +- internal/tools/file.go | 192 +---- internal/tools/index.go | 13 +- internal/tools/projects.go | 16 +- internal/tools/schema.go | 9 +- internal/tools/search.go | 1 + internal/tools/tools.go | 144 ++-- internal/tools/trace.go | 2 +- 16 files changed, 3000 insertions(+), 299 deletions(-) create mode 100644 internal/store/architecture.go create mode 100644 internal/store/architecture_test.go create mode 100644 internal/store/louvain.go create mode 100644 internal/tools/architecture.go diff --git a/README.md b/README.md index d557dc059..218e99068 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,17 @@ Single Go binary. No Docker, no external databases, no API keys. One command to install, say *"Index this project"* — done. -Parses source code with [tree-sitter](https://tree-sitter.github.io/tree-sitter/), extracts functions, classes, modules, call relationships, and cross-service HTTP links. Exposes the graph through 14 MCP tools for use with Claude Code, Codex CLI, Cursor, Windsurf, or any MCP-compatible client. Also includes a **CLI mode** for direct tool invocation from the shell — no MCP client needed. +Parses source code with [tree-sitter](https://tree-sitter.github.io/tree-sitter/), extracts functions, classes, modules, call relationships, and cross-service HTTP links. Exposes the graph through 12 MCP tools for use with Claude Code, Codex CLI, Cursor, Windsurf, or any MCP-compatible client. Also includes a **CLI mode** for direct tool invocation from the shell — no MCP client needed. ## Features - **35 languages**: Python, Go, JavaScript, TypeScript, TSX, Rust, Java, C++, C#, C, PHP, Lua, Scala, Kotlin, Ruby, Bash, Zig, Elixir, Haskell, OCaml, Objective-C, Swift, Dart, Perl, Groovy, Erlang, R, HTML, CSS, SCSS, YAML, TOML, HCL, SQL, Dockerfile +- **Architecture overview**: `get_architecture` returns languages, packages, entry points, routes, hotspots, boundaries, layers, and clusters in a single call — instant codebase orientation +- **Architecture Decision Records**: `manage_adr` persists architectural decisions (PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY) across sessions with section filtering and validation +- **Louvain community detection**: Discovers hidden functional modules across packages by clustering CALLS, HTTP_CALLS, and ASYNC_CALLS edges - **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected graph symbols + blast radius with risk classification (CRITICAL/HIGH/MEDIUM/LOW) - **Risk-classified tracing**: `trace_call_path` with `risk_labels=true` adds impact classification to every node in the call chain +- **Case-insensitive search**: `search_graph` and `search_code` are case-insensitive by default — set `case_sensitive=true` for exact matching - **One-command install**: `codebase-memory-mcp install` auto-detects Claude Code, Codex CLI, Cursor, and Windsurf, registers the MCP server, and installs task-specific skills - **Self-update**: `codebase-memory-mcp update` downloads the latest release, verifies checksums, and atomically swaps the binary - **Task-specific skills**: 4 skills (exploring, tracing, quality, reference) that prescribe exact tool sequences — Claude Code automatically uses graph tools instead of defaulting to grep @@ -239,7 +243,7 @@ Add the MCP server to your project's `.mcp.json` (per-project, recommended) or ` } ``` -Restart Claude Code after adding the config. Verify with `/mcp` — you should see `codebase-memory-mcp` listed with 14 tools. +Restart Claude Code after adding the config. Verify with `/mcp` — you should see `codebase-memory-mcp` listed with 12 tools. @@ -331,22 +335,22 @@ The CLI uses the same SQLite database as the MCP server (`~/.cache/codebase-memo | Tool | Key Parameters | Description | |------|---------------|-------------| -| `search_graph` | `label`, `name_pattern`, `project`, `file_pattern`, `relationship`, `direction`, `min_degree`, `max_degree`, `exclude_entry_points`, `limit` (default 100), `offset` | Structured search with filters. Use `project` to scope to a single repo when multiple are indexed. Supports pagination via `limit`/`offset` — response includes `has_more` and `total`. | +| `search_graph` | `label`, `name_pattern`, `project`, `file_pattern`, `relationship`, `direction`, `min_degree`, `max_degree`, `exclude_entry_points`, `case_sensitive`, `limit` (default 100), `offset` | Structured search with filters. **Case-insensitive by default** (set `case_sensitive=true` for exact case). Use `project` to scope to a single repo when multiple are indexed. Supports pagination via `limit`/`offset` — response includes `has_more` and `total`. | | `trace_call_path` | `function_name` (required), `direction` (inbound/outbound/both), `depth` (1-5, default 3), `risk_labels` (boolean) | BFS traversal from/to a function (exact name match). Returns call chains with signatures, constants, and edge types. Capped at 200 nodes. With `risk_labels=true`, adds CRITICAL/HIGH/MEDIUM/LOW classification and `impact_summary`. | | `detect_changes` | `scope` (unstaged/staged/all/branch), `base_branch`, `depth` (1-5, default 3) | Map git diff to affected graph symbols + blast radius. Returns changed files, changed symbols, and impacted callers with risk classification. Requires git in PATH. | -| `query_graph` | `query` (required) | Execute Cypher-like graph queries (read-only). See [Supported Cypher Subset](#supported-cypher-subset) for what's supported. | +| `query_graph` | `query` (required) | Execute Cypher-like graph queries (read-only). String matching in WHERE is case-sensitive by default — use `(?i)` flag for case-insensitive regex. See [Supported Cypher Subset](#supported-cypher-subset). | | `get_graph_schema` | — | Node/edge counts, relationship patterns, sample names. Run this first to understand what's in the graph. | | `get_code_snippet` | `qualified_name` (required) | Read source code for a function by its qualified name (reads from disk). See [Qualified Names](#qualified-names) for the format. | +| `get_architecture` | `aspects` (array, default `["all"]`), `project` | Codebase architecture overview computed from the code graph. Aspects: `languages`, `packages`, `entry_points`, `routes`, `hotspots`, `boundaries`, `services`, `layers` (heuristic), `clusters` (Louvain community detection), `file_tree`, `adr` (stored Architecture Decision Record). Call with `["all"]` for full orientation. | +| `manage_adr` | `mode` (required: `get`/`store`/`update`/`delete`), `project`, `content`, `sections` | CRUD for Architecture Decision Records. `get`: retrieve ADR with parsed sections. `store`: create/replace full ADR (max 8000 chars). `update`: patch specific sections (unmentioned preserved). `delete`: remove ADR. Fixed sections: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. | ### File Access -> **Note**: These tools require at least one indexed project. They resolve relative paths against indexed project roots. Index a project first with `index_repository`. +> **Note**: File reading and directory listing are handled natively by your coding agent (Claude Code `Read` tool, Codex CLI `cat`/`ls`, etc.). The tools below provide text search within indexed project files. | Tool | Key Parameters | Description | |------|---------------|-------------| -| `search_code` | `pattern` (required), `file_pattern`, `regex`, `max_results` (default 100), `offset` | Grep-like text search within indexed project files. Supports pagination via `max_results`/`offset`. | -| `read_file` | `path`, `start_line`, `end_line` | Read any file from an indexed project. Path can be absolute or relative to project root. | -| `list_directory` | `path`, `pattern` | List files/directories with optional glob filtering (e.g. `*.go`, `*.py`). | +| `search_code` | `pattern` (required), `file_pattern`, `regex`, `case_sensitive`, `max_results` (default 100), `offset` | Grep-like text search within indexed project files. **Case-insensitive by default** (set `case_sensitive=true` for exact case). Supports pagination via `max_results`/`offset`. | ## Usage Examples @@ -356,10 +360,64 @@ The CLI uses the same SQLite database as the MCP server (`~/.cache/codebase-memo index_repository(repo_path="/path/to/your/project") ``` -### Find all functions matching a pattern +### Get codebase architecture overview ``` -search_graph(label="Function", name_pattern=".*Handler") +get_architecture(aspects=["all"]) +# → languages, packages, entry points, routes, hotspots, boundaries, services, layers, clusters, file tree + +get_architecture(aspects=["languages", "packages"]) +# → quick orientation — just language breakdown and top packages + +get_architecture(aspects=["hotspots", "boundaries", "clusters"]) +# → dependency analysis — most-called functions, cross-package calls, community detection +``` + +### Manage Architecture Decision Records (ADR) + +``` +# Store a new ADR +manage_adr(mode="store", content="## PURPOSE\nOrder processing service\n\n## STACK\n- Go: speed\n- SQLite: embedded storage") + +# Update specific sections (others preserved) +manage_adr(mode="update", sections={"PATTERNS": "- Pipeline pattern\n- Repository pattern"}) + +# Retrieve the full ADR with parsed sections +manage_adr(mode="get") + +# View ADR via architecture overview +get_architecture(aspects=["adr"]) + +# Delete the ADR +manage_adr(mode="delete") +``` + +### Find all functions matching a pattern + +Search is **case-insensitive by default** — no need for `(?i)`: + +``` +search_graph(label="Function", name_pattern=".*handler") +# → matches "Handler", "handler", "HANDLER", "RequestHandler", etc. + +# Use regex alternatives for broad matching: +search_graph(name_pattern="auth|authenticate|authorization") + +# Opt in to exact case matching when needed: +search_graph(name_pattern=".*Handler", case_sensitive=true) +``` + +### Search code (text search) + +``` +search_code(pattern="TODO") +# → case-insensitive by default, matches "TODO", "Todo", "todo" + +search_code(pattern="TODO|FIXME|HACK", regex=true) +# → find all issue markers + +search_code(pattern="TODO", case_sensitive=true) +# → exact case match only ``` ### Trace what a function calls @@ -418,6 +476,11 @@ search_graph(label="Route") query_graph(query="MATCH (f:Function)-[:CALLS]->(g:Function) WHERE f.name = 'main' RETURN g.name, g.qualified_name LIMIT 20") ``` +``` +# Case-insensitive regex in Cypher (use (?i) flag): +query_graph(query="MATCH (f:Function) WHERE f.name =~ '(?i).*handler.*' RETURN f.name LIMIT 20") +``` + ``` query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 10") ``` @@ -589,7 +652,7 @@ make install # go install |---------|-------|-----| | `/mcp` doesn't show the server | Config not loaded or binary not found | Check `.mcp.json` path is absolute and correct. Restart Claude Code. Verify binary runs: `/path/to/codebase-memory-mcp` should output JSON. | | `index_repository` fails | Missing `repo_path` or path doesn't exist | Pass an absolute path: `index_repository(repo_path="/absolute/path")` | -| `read_file` / `list_directory` returns error | No project indexed yet | Run `index_repository` first. These tools resolve paths against indexed project roots. | +| `get_architecture` returns empty sections | No project indexed or project has few nodes | Run `index_repository` first. Some aspects (routes, hotspots, clusters) require enough graph data to produce meaningful results. | | `get_code_snippet` returns "node not found" | Wrong qualified name format | Use `search_graph` first to find the exact `qualified_name`, then pass it to `get_code_snippet`. See [Qualified Names](#qualified-names). | | `trace_call_path` returns 0 results | Exact name match — no fuzzy matching | Use `search_graph(name_pattern=".*PartialName.*")` to discover the exact function name first. | | Queries return results from wrong project | Multiple projects indexed, no filter | Add `project="your-project-name"` to `search_graph`. Use `list_projects` to see indexed project names. | @@ -617,17 +680,18 @@ See [`BENCHMARK.md`](BENCHMARK.md) for the full 35-language benchmark with per-q ``` cmd/codebase-memory-mcp/ Entry point (MCP stdio server + CLI mode + install/update commands) internal/ - store/ SQLite graph storage (nodes, edges, traversal, search) + store/ SQLite graph storage (nodes, edges, traversal, search, architecture, Louvain clustering) lang/ Language specs (35 languages, tree-sitter node types) parser/ Tree-sitter grammar loading and AST parsing - pipeline/ 4-pass indexing (structure -> definitions -> calls -> HTTP links) + pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → communities → tests) httplink/ Cross-service HTTP route/call-site matching cypher/ Cypher query lexer, parser, planner, executor selfupdate/ GitHub release checking, version comparison, asset download - tools/ MCP tool handlers (14 tools) + CLI dispatch + tools/ MCP tool handlers (12 tools) + CLI dispatch watcher/ Background auto-sync (mtime+size polling, adaptive intervals) discover/ File discovery with .cgrignore support fqn/ Qualified name computation + traces/ OpenTelemetry trace ingestion for HTTP_CALLS validation ``` ## License diff --git a/internal/pipeline/communities.go b/internal/pipeline/communities.go index 606403ce1..2ab5cdc5a 100644 --- a/internal/pipeline/communities.go +++ b/internal/pipeline/communities.go @@ -9,7 +9,7 @@ import ( "github.com/DeusData/codebase-memory-mcp/internal/store" ) -// passCommunities runs Leiden-style community detection on the CALLS graph +// passCommunities runs Louvain community detection on the CALLS graph // and creates Community nodes + MEMBER_OF edges. func (p *Pipeline) passCommunities() { slog.Info("pass.communities") @@ -37,7 +37,7 @@ func (p *Pipeline) passCommunities() { adj[e.TargetID][e.SourceID] = true } - // Run Louvain/Leiden community detection + // Run Louvain community detection communities := louvainCommunities(adj, allNodes) // Create Community nodes + MEMBER_OF edges diff --git a/internal/store/architecture.go b/internal/store/architecture.go new file mode 100644 index 000000000..60b9387c9 --- /dev/null +++ b/internal/store/architecture.go @@ -0,0 +1,1126 @@ +package store + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// ArchitectureInfo holds the result of a codebase architecture analysis. +type ArchitectureInfo struct { + Languages []LanguageCount `json:"languages,omitempty"` + Packages []PackageSummary `json:"packages,omitempty"` + EntryPoints []EntryPointInfo `json:"entry_points,omitempty"` + Routes []RouteInfo `json:"routes,omitempty"` + Hotspots []HotspotFunction `json:"hotspots,omitempty"` + Boundaries []CrossPkgBoundary `json:"boundaries,omitempty"` + Services []ServiceLink `json:"services,omitempty"` + Layers []PackageLayer `json:"layers,omitempty"` + Clusters []ClusterInfo `json:"clusters,omitempty"` + FileTree []FileTreeEntry `json:"file_tree,omitempty"` +} + +// LanguageCount counts files per language. +type LanguageCount struct { + Language string `json:"language"` + FileCount int `json:"file_count"` +} + +// PackageSummary summarizes a package with its connectivity. +type PackageSummary struct { + Name string `json:"name"` + NodeCount int `json:"node_count"` + FanIn int `json:"fan_in"` + FanOut int `json:"fan_out"` +} + +// EntryPointInfo describes an entry point function. +type EntryPointInfo struct { + Name string `json:"name"` + QualifiedName string `json:"qualified_name"` + File string `json:"file"` +} + +// RouteInfo describes an HTTP route. +type RouteInfo struct { + Method string `json:"method"` + Path string `json:"path"` + Handler string `json:"handler"` +} + +// HotspotFunction is a function with high fan-in. +type HotspotFunction struct { + Name string `json:"name"` + QualifiedName string `json:"qualified_name"` + FanIn int `json:"fan_in"` +} + +// CrossPkgBoundary represents cross-package call volume. +type CrossPkgBoundary struct { + From string `json:"from"` + To string `json:"to"` + CallCount int `json:"call_count"` +} + +// ServiceLink represents a cross-service link (HTTP or async). +type ServiceLink struct { + From string `json:"from"` + To string `json:"to"` + Type string `json:"type"` + Count int `json:"count"` +} + +// PackageLayer classifies a package into an architectural layer. +type PackageLayer struct { + Name string `json:"name"` + Layer string `json:"layer"` + Reason string `json:"reason"` +} + +// ClusterInfo describes a community detected by the Louvain algorithm. +type ClusterInfo struct { + ID int `json:"id"` + Label string `json:"label"` + Members int `json:"members"` + Cohesion float64 `json:"cohesion"` + TopNodes []string `json:"top_nodes"` + Packages []string `json:"packages"` + EdgeTypes []string `json:"edge_types"` +} + +// FileTreeEntry describes a node in the condensed file tree. +type FileTreeEntry struct { + Path string `json:"path"` + Type string `json:"type"` + Children int `json:"children"` +} + +// buildAspectSet converts the aspects slice into a lookup set. +// An empty list or a list containing "all" means every aspect is wanted. +func buildAspectSet(aspects []string) map[string]bool { + set := make(map[string]bool, len(aspects)) + for _, a := range aspects { + set[a] = true + } + if len(set) == 0 || set["all"] { + for _, name := range []string{ + "languages", "packages", "entry_points", "routes", "hotspots", + "boundaries", "services", "layers", "clusters", "file_tree", + } { + set[name] = true + } + } + return set +} + +// fetchAspects dispatches each requested aspect to its query method. +func (s *Store) fetchAspects(project string, info *ArchitectureInfo, want map[string]bool) error { + type aspectEntry struct { + name string + fn func() error + } + entries := []aspectEntry{ + {"languages", func() error { var e error; info.Languages, e = s.archLanguages(project); return e }}, + {"packages", func() error { var e error; info.Packages, e = s.archPackages(project); return e }}, + {"entry_points", func() error { var e error; info.EntryPoints, e = s.archEntryPoints(project); return e }}, + {"routes", func() error { var e error; info.Routes, e = s.archRoutes(project); return e }}, + {"hotspots", func() error { var e error; info.Hotspots, e = s.archHotspots(project); return e }}, + {"boundaries", func() error { var e error; info.Boundaries, e = s.archBoundaries(project); return e }}, + {"services", func() error { var e error; info.Services, e = s.archServices(project); return e }}, + {"layers", func() error { var e error; info.Layers, e = s.archLayers(project); return e }}, + {"clusters", func() error { var e error; info.Clusters, e = s.archClusters(project); return e }}, + {"file_tree", func() error { var e error; info.FileTree, e = s.archFileTree(project); return e }}, + } + for _, entry := range entries { + if !want[entry.name] { + continue + } + if err := entry.fn(); err != nil { + return fmt.Errorf("%s: %w", entry.name, err) + } + } + return nil +} + +// GetArchitecture computes architecture aspects for a project. +// When aspects contains "all" or is empty, all aspects are computed. +func (s *Store) GetArchitecture(project string, aspects []string) (*ArchitectureInfo, error) { + want := buildAspectSet(aspects) + info := &ArchitectureInfo{} + if err := s.fetchAspects(project, info, want); err != nil { + return nil, err + } + return info, nil +} + +// --- Aspect implementations --- + +// extToLang maps file extensions to language names. +var extToLang = map[string]string{ + ".py": "Python", ".go": "Go", ".js": "JavaScript", ".ts": "TypeScript", + ".tsx": "TypeScript", ".jsx": "JavaScript", ".rs": "Rust", ".java": "Java", + ".cpp": "C++", ".cc": "C++", ".cxx": "C++", ".c": "C", ".h": "C", + ".cs": "C#", ".php": "PHP", ".lua": "Lua", ".scala": "Scala", + ".kt": "Kotlin", ".rb": "Ruby", ".sh": "Bash", ".bash": "Bash", + ".zig": "Zig", ".ex": "Elixir", ".exs": "Elixir", ".hs": "Haskell", + ".ml": "OCaml", ".mli": "OCaml", ".html": "HTML", ".css": "CSS", + ".yaml": "YAML", ".yml": "YAML", ".toml": "TOML", ".hcl": "HCL", + ".tf": "HCL", ".sql": "SQL", ".erl": "Erlang", ".swift": "Swift", + ".dart": "Dart", ".groovy": "Groovy", ".pl": "Perl", ".r": "R", + ".scss": "SCSS", ".vue": "Vue", ".svelte": "Svelte", +} + +func (s *Store) archLanguages(project string) ([]LanguageCount, error) { + rows, err := s.q.Query(`SELECT file_path FROM nodes WHERE project=? AND label='File'`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := map[string]int{} + for rows.Next() { + var fp string + if err := rows.Scan(&fp); err != nil { + return nil, err + } + ext := strings.ToLower(filepath.Ext(fp)) + lang := extToLang[ext] + if lang == "" { + continue + } + counts[lang]++ + } + if err := rows.Err(); err != nil { + return nil, err + } + + result := make([]LanguageCount, 0, len(counts)) + for lang, cnt := range counts { + result = append(result, LanguageCount{Language: lang, FileCount: cnt}) + } + sort.Slice(result, func(i, j int) bool { return result[i].FileCount > result[j].FileCount }) + if len(result) > 10 { + result = result[:10] + } + return result, nil +} + +func (s *Store) archPackages(project string) ([]PackageSummary, error) { + // Get all packages with node counts + rows, err := s.q.Query(` + SELECT n.name, COUNT(*) as cnt + FROM nodes n + WHERE n.project=? AND n.label='Package' + GROUP BY n.name + ORDER BY cnt DESC LIMIT 15`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + // First get package names + type pkgRaw struct { + name string + count int + } + var pkgs []pkgRaw + for rows.Next() { + var p pkgRaw + if err := rows.Scan(&p.name, &p.count); err != nil { + return nil, err + } + pkgs = append(pkgs, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Fallback: count nodes per QN prefix segment if no Package nodes + if len(pkgs) == 0 { + return s.archPackagesByQN(project) + } + + result := make([]PackageSummary, len(pkgs)) + for i, p := range pkgs { + result[i] = PackageSummary{Name: p.name, NodeCount: p.count} + } + return result, nil +} + +// archPackagesByQN groups nodes by the sub-package segment of their qualified name. +func (s *Store) archPackagesByQN(project string) ([]PackageSummary, error) { + rows, err := s.q.Query(`SELECT qualified_name FROM nodes WHERE project=? AND label IN ('Function','Method','Class')`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := map[string]int{} + for rows.Next() { + var qn string + if err := rows.Scan(&qn); err != nil { + return nil, err + } + pkg := qnToPackage(qn) + if pkg == "" { + continue + } + counts[pkg]++ + } + if err := rows.Err(); err != nil { + return nil, err + } + + result := make([]PackageSummary, 0, len(counts)) + for name, cnt := range counts { + result = append(result, PackageSummary{Name: name, NodeCount: cnt}) + } + sort.Slice(result, func(i, j int) bool { return result[i].NodeCount > result[j].NodeCount }) + if len(result) > 15 { + result = result[:15] + } + return result, nil +} + +func (s *Store) archEntryPoints(project string) ([]EntryPointInfo, error) { + rows, err := s.q.Query(` + SELECT name, qualified_name, file_path FROM nodes + WHERE project=? AND json_extract(properties, '$.is_entry_point') = 1 + AND (json_extract(properties, '$.is_test') IS NULL OR json_extract(properties, '$.is_test') != 1) + AND file_path NOT LIKE '%test%' + LIMIT 20`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []EntryPointInfo + for rows.Next() { + var ep EntryPointInfo + if err := rows.Scan(&ep.Name, &ep.QualifiedName, &ep.File); err != nil { + return nil, err + } + result = append(result, ep) + } + return result, rows.Err() +} + +func (s *Store) archRoutes(project string) ([]RouteInfo, error) { + rows, err := s.q.Query(` + SELECT name, properties, COALESCE(file_path, '') FROM nodes + WHERE project=? AND label='Route' + AND (json_extract(properties, '$.is_test') IS NULL OR json_extract(properties, '$.is_test') != 1) + LIMIT 20`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []RouteInfo + for rows.Next() { + var name, props, fp string + if err := rows.Scan(&name, &props, &fp); err != nil { + return nil, err + } + if isTestFilePath(fp) { + continue + } + p := unmarshalProps(props) + ri := RouteInfo{ + Path: name, + } + if m, ok := p["method"].(string); ok { + ri.Method = m + } + if path, ok := p["path"].(string); ok { + ri.Path = path + } + if h, ok := p["handler"].(string); ok { + ri.Handler = h + } + result = append(result, ri) + } + return result, rows.Err() +} + +func (s *Store) archHotspots(project string) ([]HotspotFunction, error) { + rows, err := s.q.Query(` + SELECT n.name, n.qualified_name, COUNT(*) as fan_in + FROM nodes n + JOIN edges e ON e.target_id = n.id AND e.type = 'CALLS' + WHERE n.project=? AND n.label IN ('Function', 'Method') + AND (json_extract(n.properties, '$.is_test') IS NULL OR json_extract(n.properties, '$.is_test') != 1) + AND n.file_path NOT LIKE '%test%' + GROUP BY n.id + ORDER BY fan_in DESC + LIMIT 10`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []HotspotFunction + for rows.Next() { + var h HotspotFunction + if err := rows.Scan(&h.Name, &h.QualifiedName, &h.FanIn); err != nil { + return nil, err + } + result = append(result, h) + } + return result, rows.Err() +} + +func (s *Store) archBoundaries(project string) ([]CrossPkgBoundary, error) { + // Build node ID → package name map via QN prefix (only callable nodes) + nodeRows, err := s.q.Query(`SELECT id, qualified_name FROM nodes WHERE project=? AND label IN ('Function','Method','Class')`, project) + if err != nil { + return nil, err + } + defer nodeRows.Close() + + nodePkg := map[int64]string{} + for nodeRows.Next() { + var id int64 + var qn string + if err := nodeRows.Scan(&id, &qn); err != nil { + return nil, err + } + nodePkg[id] = qnToPackage(qn) + } + if err := nodeRows.Err(); err != nil { + return nil, err + } + + // Single-pass edge scan grouping cross-package calls + edgeRows, err := s.q.Query(`SELECT source_id, target_id FROM edges WHERE project=? AND type='CALLS'`, project) + if err != nil { + return nil, err + } + defer edgeRows.Close() + + type boundaryKey struct{ from, to string } + counts := map[boundaryKey]int{} + for edgeRows.Next() { + var srcID, tgtID int64 + if err := edgeRows.Scan(&srcID, &tgtID); err != nil { + return nil, err + } + srcPkg := nodePkg[srcID] + tgtPkg := nodePkg[tgtID] + if srcPkg != tgtPkg && srcPkg != "" && tgtPkg != "" { + counts[boundaryKey{srcPkg, tgtPkg}]++ + } + } + if err := edgeRows.Err(); err != nil { + return nil, err + } + + result := make([]CrossPkgBoundary, 0, len(counts)) + for k, cnt := range counts { + result = append(result, CrossPkgBoundary{From: k.from, To: k.to, CallCount: cnt}) + } + sort.Slice(result, func(i, j int) bool { return result[i].CallCount > result[j].CallCount }) + if len(result) > 10 { + result = result[:10] + } + return result, nil +} + +func (s *Store) archServices(project string) ([]ServiceLink, error) { + // Build node ID → top-level package map first (before opening edge cursor) + nodePkg, err := s.loadNodePackageMap(project) + if err != nil { + return nil, err + } + + // Query edges + type linkKey struct{ from, to, typ string } + counts := map[linkKey]int{} + + edgeRows, err := s.q.Query(` + SELECT source_id, target_id, type + FROM edges WHERE project=? AND type IN ('HTTP_CALLS', 'ASYNC_CALLS')`, project) + if err != nil { + return nil, err + } + defer edgeRows.Close() + + for edgeRows.Next() { + var srcID, tgtID int64 + var edgeType string + if err := edgeRows.Scan(&srcID, &tgtID, &edgeType); err != nil { + return nil, err + } + from := nodePkg[srcID] + to := nodePkg[tgtID] + if from != "" && to != "" { + counts[linkKey{from, to, edgeType}]++ + } + } + if err := edgeRows.Err(); err != nil { + return nil, err + } + + result := make([]ServiceLink, 0, len(counts)) + for k, cnt := range counts { + result = append(result, ServiceLink{From: k.from, To: k.to, Type: k.typ, Count: cnt}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count }) + if len(result) > 10 { + result = result[:10] + } + return result, nil +} + +func (s *Store) archLayers(project string) ([]PackageLayer, error) { + // Get boundaries for fan-in/out analysis + boundaries, err := s.archBoundaries(project) + if err != nil { + return nil, err + } + + // Check which packages have Route nodes + routePkgs := map[string]bool{} + routeRows, err := s.q.Query(`SELECT qualified_name FROM nodes WHERE project=? AND label='Route'`, project) + if err != nil { + return nil, err + } + defer routeRows.Close() + for routeRows.Next() { + var qn string + if err := routeRows.Scan(&qn); err != nil { + return nil, err + } + routePkgs[qnToPackage(qn)] = true + } + if err := routeRows.Err(); err != nil { + return nil, err + } + + // Check which packages have entry points + entryPkgs := map[string]bool{} + entryRows, err := s.q.Query(` + SELECT qualified_name FROM nodes + WHERE project=? AND json_extract(properties, '$.is_entry_point') = 1`, project) + if err != nil { + return nil, err + } + defer entryRows.Close() + for entryRows.Next() { + var qn string + if err := entryRows.Scan(&qn); err != nil { + return nil, err + } + entryPkgs[qnToPackage(qn)] = true + } + if err := entryRows.Err(); err != nil { + return nil, err + } + + // Compute fan-in/out per package from boundaries + fanIn := map[string]int{} + fanOut := map[string]int{} + allPkgs := map[string]bool{} + for _, b := range boundaries { + fanOut[b.From] += b.CallCount + fanIn[b.To] += b.CallCount + allPkgs[b.From] = true + allPkgs[b.To] = true + } + + // Also include packages from entry points and routes + for pkg := range entryPkgs { + allPkgs[pkg] = true + } + for pkg := range routePkgs { + allPkgs[pkg] = true + } + + var result []PackageLayer + for pkg := range allPkgs { + layer, reason := classifyLayer(pkg, fanIn[pkg], fanOut[pkg], routePkgs[pkg], entryPkgs[pkg]) + result = append(result, PackageLayer{Name: pkg, Layer: layer, Reason: reason}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, nil +} + +func classifyLayer(_ string, in, out int, hasRoutes, hasEntryPoints bool) (layer, reason string) { + if hasEntryPoints && out > 0 && in == 0 { + return "entry", "has entry points, only outbound calls" + } + if hasRoutes { + return "api", "has HTTP route definitions" + } + if in > out && in > 3 { + return "core", fmt.Sprintf("high fan-in (%d in, %d out)", in, out) + } + if out == 0 && in > 0 { + return "leaf", "only inbound calls, no outbound" + } + if in == 0 && out > 0 { + return "entry", "only outbound calls" + } + return "internal", fmt.Sprintf("fan-in=%d, fan-out=%d", in, out) +} + +// clusterNodeInfo holds node metadata for the clustering algorithm. +type clusterNodeInfo struct { + id int64 + name string + qn string +} + +func (s *Store) archClusters(project string) ([]ClusterInfo, error) { + // Load all function/method nodes + nodeRows, err := s.q.Query(`SELECT id, name, qualified_name FROM nodes WHERE project=? AND label IN ('Function', 'Method')`, project) + if err != nil { + return nil, err + } + defer nodeRows.Close() + + var nodeList []clusterNodeInfo + nodeIDSet := map[int64]bool{} + for nodeRows.Next() { + var ni clusterNodeInfo + if err := nodeRows.Scan(&ni.id, &ni.name, &ni.qn); err != nil { + return nil, err + } + nodeList = append(nodeList, ni) + nodeIDSet[ni.id] = true + } + if err := nodeRows.Err(); err != nil { + return nil, err + } + + if len(nodeList) < 3 { + return nil, nil // too few nodes for meaningful clustering + } + + // Load edges (CALLS + HTTP_CALLS + ASYNC_CALLS) + edgeRows, err := s.q.Query(` + SELECT source_id, target_id, type FROM edges + WHERE project=? AND type IN ('CALLS', 'HTTP_CALLS', 'ASYNC_CALLS')`, project) + if err != nil { + return nil, err + } + defer edgeRows.Close() + + var edges []louvainEdge + for edgeRows.Next() { + var e louvainEdge + if err := edgeRows.Scan(&e.src, &e.dst, &e.edgeType); err != nil { + return nil, err + } + // Only include edges between function/method nodes + if nodeIDSet[e.src] && nodeIDSet[e.dst] { + edges = append(edges, e) + } + } + if err := edgeRows.Err(); err != nil { + return nil, err + } + + if len(edges) < 3 { + return nil, nil + } + + // Build node ID list + nodeIDs := make([]int64, len(nodeList)) + for i, ni := range nodeList { + nodeIDs[i] = ni.id + } + + // Run Louvain algorithm + partition := louvain(nodeIDs, edges) + + // Build lookup maps + nodeByID := map[int64]clusterNodeInfo{} + for _, ni := range nodeList { + nodeByID[ni.id] = ni + } + + // Group nodes by community + communities := map[int][]int64{} + for nodeID, comm := range partition { + communities[comm] = append(communities[comm], nodeID) + } + + // Build edge type sets per community + commEdgeTypes := map[int]map[string]bool{} + for _, e := range edges { + srcComm := partition[e.src] + dstComm := partition[e.dst] + if srcComm == dstComm { + if commEdgeTypes[srcComm] == nil { + commEdgeTypes[srcComm] = map[string]bool{} + } + commEdgeTypes[srcComm][e.edgeType] = true + } + } + + // Compute fan-in per node for ranking + fanIn := map[int64]int{} + for _, e := range edges { + fanIn[e.dst]++ + } + + clusters := buildClusterInfos(communities, edges, commEdgeTypes, fanIn, nodeByID) + return clusters, nil +} + +// buildClusterInfos converts community assignments into sorted, capped ClusterInfo slices. +func buildClusterInfos(communities map[int][]int64, edges []louvainEdge, commEdgeTypes map[int]map[string]bool, fanIn map[int64]int, nodeByID map[int64]clusterNodeInfo) []ClusterInfo { + var clusters []ClusterInfo + for commID, members := range communities { + if len(members) < 2 { + continue + } + ci := buildOneCluster(commID, members, edges, commEdgeTypes[commID], fanIn, nodeByID) + clusters = append(clusters, ci) + } + sort.Slice(clusters, func(i, j int) bool { return clusters[i].Members > clusters[j].Members }) + if len(clusters) > 15 { + clusters = clusters[:15] + } + for i := range clusters { + clusters[i].ID = i + 1 + } + return clusters +} + +// buildOneCluster computes a ClusterInfo for a single community. +func buildOneCluster(commID int, members []int64, edges []louvainEdge, edgeTypes map[string]bool, fanIn map[int64]int, nodeByID map[int64]clusterNodeInfo) ClusterInfo { + memberSet := map[int64]bool{} + for _, id := range members { + memberSet[id] = true + } + + var internalEdges, totalEdges int + for _, e := range edges { + isSrc := memberSet[e.src] + isDst := memberSet[e.dst] + if isSrc || isDst { + totalEdges++ + } + if isSrc && isDst { + internalEdges++ + } + } + cohesion := 0.0 + if totalEdges > 0 { + cohesion = float64(internalEdges) / float64(totalEdges) + } + + sort.Slice(members, func(i, j int) bool { return fanIn[members[i]] > fanIn[members[j]] }) + topN := 5 + if len(members) < topN { + topN = len(members) + } + topNodes := make([]string, topN) + for i := 0; i < topN; i++ { + topNodes[i] = nodeByID[members[i]].name + } + + pkgSet := map[string]bool{} + for _, id := range members { + if pkg := qnToPackage(nodeByID[id].qn); pkg != "" { + pkgSet[pkg] = true + } + } + pkgs := make([]string, 0, len(pkgSet)) + for pkg := range pkgSet { + pkgs = append(pkgs, pkg) + } + sort.Strings(pkgs) + + etypes := make([]string, 0, len(edgeTypes)) + for et := range edgeTypes { + etypes = append(etypes, et) + } + sort.Strings(etypes) + + return ClusterInfo{ + ID: commID, + Label: autoNameCluster(members, nodeByID), + Members: len(members), + Cohesion: cohesion, + TopNodes: topNodes, + Packages: pkgs, + EdgeTypes: etypes, + } +} + +func autoNameCluster(members []int64, nodeByID map[int64]clusterNodeInfo) string { + pkgCounts := map[string]int{} + for _, id := range members { + pkg := qnToPackage(nodeByID[id].qn) + if pkg != "" { + pkgCounts[pkg]++ + } + } + bestPkg := "" + bestCount := 0 + for pkg, cnt := range pkgCounts { + if cnt > bestCount { + bestPkg = pkg + bestCount = cnt + } + } + if bestPkg != "" { + return bestPkg + } + return fmt.Sprintf("cluster-%d", len(members)) +} + +func (s *Store) archFileTree(project string) ([]FileTreeEntry, error) { + rows, err := s.q.Query(`SELECT file_path FROM nodes WHERE project=? AND label='File'`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + dirChildren := map[string]map[string]bool{} + fileSet := map[string]bool{} + + for rows.Next() { + var fp string + if err := rows.Scan(&fp); err != nil { + return nil, err + } + fileSet[fp] = true + registerFilePath(fp, dirChildren) + } + if err := rows.Err(); err != nil { + return nil, err + } + + result := collectTreeEntries(dirChildren, fileSet) + sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path }) + return result, nil +} + +// registerFilePath registers a file path in the directory tree up to 3 levels deep. +func registerFilePath(fp string, dirChildren map[string]map[string]bool) { + parts := strings.Split(fp, "/") + for depth := 0; depth < len(parts)-1 && depth < 3; depth++ { + dir := strings.Join(parts[:depth+1], "/") + child := "" + if depth+1 < len(parts) { + child = parts[depth+1] + } + if dirChildren[dir] == nil { + dirChildren[dir] = map[string]bool{} + } + if child != "" { + dirChildren[dir][child] = true + } + } + if len(parts) > 0 { + if dirChildren[""] == nil { + dirChildren[""] = map[string]bool{} + } + dirChildren[""][parts[0]] = true + } +} + +// collectTreeEntries builds FileTreeEntry slices from the directory map. +func collectTreeEntries(dirChildren map[string]map[string]bool, fileSet map[string]bool) []FileTreeEntry { + var result []FileTreeEntry + if rootChildren, ok := dirChildren[""]; ok { + for child := range rootChildren { + entryType := "dir" + if fileSet[child] { + entryType = "file" + } + result = append(result, FileTreeEntry{Path: child, Type: entryType, Children: len(dirChildren[child])}) + } + } + for dir, children := range dirChildren { + if dir == "" || strings.Count(dir, "/") >= 3 { + continue + } + for child := range children { + path := dir + "/" + child + entryType := "dir" + if fileSet[path] { + entryType = "file" + } + result = append(result, FileTreeEntry{Path: path, Type: entryType, Children: len(dirChildren[path])}) + } + } + return result +} + +// --- Helpers --- + +// loadNodePackageMap returns nodeID → top-level package for all nodes in a project. +func (s *Store) loadNodePackageMap(project string) (map[int64]string, error) { + nodePkg := map[int64]string{} + rows, err := s.q.Query(`SELECT id, qualified_name FROM nodes WHERE project=?`, project) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int64 + var qn string + if err := rows.Scan(&id, &qn); err != nil { + return nil, err + } + nodePkg[id] = qnToTopPackage(qn) + } + return nodePkg, rows.Err() +} + +// qnToPackage extracts the meaningful sub-package from a qualified name. +// QN format: project.dir1.dir2.filestem.Symbol — segment[2] is the sub-package. +// For shallow layouts (project.dir.symbol), falls back to segment[1]. +func qnToPackage(qn string) string { + parts := strings.SplitN(qn, ".", 5) + if len(parts) >= 4 { + return parts[2] // sub-package (e.g., "store" from "project.internal.store.search.Search") + } + if len(parts) >= 2 { + return parts[1] // flat layout fallback + } + return "" +} + +// qnToTopPackage extracts the top-level directory from a qualified name (segment[1]). +// Used for coarse-grained grouping in service detection. +func qnToTopPackage(qn string) string { + parts := strings.SplitN(qn, ".", 3) + if len(parts) >= 2 { + return parts[1] + } + return "" +} + +// --- Architecture Decision Record (ADR) --- + +// maxADRLength is the maximum allowed length for an ADR in characters. +const maxADRLength = 8000 + +// MaxADRLength returns the maximum allowed ADR length for use by tool handlers. +func MaxADRLength() int { return maxADRLength } + +// canonicalSections defines the fixed set of ADR section headers in canonical order. +var canonicalSections = []string{"PURPOSE", "STACK", "ARCHITECTURE", "PATTERNS", "TRADEOFFS", "PHILOSOPHY"} + +// canonicalSet is a lookup for canonical section names. +var canonicalSet = map[string]bool{ + "PURPOSE": true, "STACK": true, "ARCHITECTURE": true, + "PATTERNS": true, "TRADEOFFS": true, "PHILOSOPHY": true, +} + +// ValidateADRContent checks that content contains all 6 canonical sections. +// Returns an error listing any missing sections. +func ValidateADRContent(content string) error { + sections := ParseADRSections(content) + var missing []string + for _, name := range canonicalSections { + if _, ok := sections[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) > 0 { + return fmt.Errorf("missing required sections: %s. All 6 required: %s", + strings.Join(missing, ", "), strings.Join(canonicalSections, ", ")) + } + return nil +} + +// ValidateADRSectionKeys checks that all keys in the map are canonical section names. +// Returns an error listing any invalid keys. +func ValidateADRSectionKeys(sections map[string]string) error { + var invalid []string + for k := range sections { + if !canonicalSet[k] { + invalid = append(invalid, k) + } + } + if len(invalid) > 0 { + sort.Strings(invalid) + return fmt.Errorf("invalid section names: %s. Valid sections: %s", + strings.Join(invalid, ", "), strings.Join(canonicalSections, ", ")) + } + return nil +} + +// CanonicalSectionNames returns the ordered list of canonical ADR section names. +func CanonicalSectionNames() []string { return canonicalSections } + +// ADRecord holds a stored Architecture Decision Record. +type ADRecord struct { + Project string `json:"project"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// StoreADR persists an ADR (upsert). The source_hash column is kept as a dead +// column to avoid ALTER TABLE issues — we write an empty string. +func (s *Store) StoreADR(project, content string) error { + now := Now() + _, err := s.q.Exec(` + INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) + VALUES (?, ?, '', ?, ?) + ON CONFLICT(project) DO UPDATE SET + summary = excluded.summary, + updated_at = excluded.updated_at`, + project, content, now, now) + return err +} + +// GetADR retrieves a stored ADR for a project. +func (s *Store) GetADR(project string) (*ADRecord, error) { + row := s.q.QueryRow(`SELECT project, summary, created_at, updated_at FROM project_summaries WHERE project=?`, project) + var r ADRecord + if err := row.Scan(&r.Project, &r.Content, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + return &r, nil +} + +// DeleteADR removes a stored ADR for a project. +func (s *Store) DeleteADR(project string) error { + res, err := s.q.Exec(`DELETE FROM project_summaries WHERE project=?`, project) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("no ADR found for project %q", project) + } + return nil +} + +// UpdateADRSections merges the provided sections into the existing ADR. +// Unmentioned sections are preserved. Returns the updated record. +// Returns an error if the merged content exceeds maxADRLength. +func (s *Store) UpdateADRSections(project string, sections map[string]string) (*ADRecord, error) { + existing, err := s.GetADR(project) + if err != nil { + return nil, fmt.Errorf("no existing ADR to update: %w", err) + } + + parsed := ParseADRSections(existing.Content) + for k, v := range sections { + parsed[k] = v + } + + merged := RenderADR(parsed) + if len(merged) > maxADRLength { + return nil, fmt.Errorf("merged ADR exceeds %d chars (%d chars); reduce section content before updating", maxADRLength, len(merged)) + } + + if err := s.StoreADR(project, merged); err != nil { + return nil, err + } + + return s.GetADR(project) +} + +// ParseADRSections splits ADR content by canonical section headers. +// Only canonical headers (PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY) +// are recognized as split boundaries. Other ## headers within content are treated as +// literal text within the current section. +func ParseADRSections(content string) map[string]string { + sections := make(map[string]string) + if content == "" { + return sections + } + + lines := strings.Split(content, "\n") + currentSection := "" + var currentContent []string + + for _, line := range lines { + if strings.HasPrefix(line, "## ") { + header := strings.TrimSpace(strings.TrimPrefix(line, "## ")) + if canonicalSet[header] { + if currentSection != "" { + sections[currentSection] = strings.TrimSpace(strings.Join(currentContent, "\n")) + } + currentSection = header + currentContent = nil + continue + } + } + currentContent = append(currentContent, line) + } + + if currentSection != "" { + sections[currentSection] = strings.TrimSpace(strings.Join(currentContent, "\n")) + } + + return sections +} + +// RenderADR joins sections into markdown with canonical sections first (in order), +// followed by any non-canonical sections alphabetically. +func RenderADR(sections map[string]string) string { + var parts []string + rendered := make(map[string]bool) + + // Canonical sections first, in order + for _, name := range canonicalSections { + if content, ok := sections[name]; ok { + parts = append(parts, "## "+name+"\n"+content) + rendered[name] = true + } + } + + // Non-canonical sections alphabetically + var extra []string + for name := range sections { + if !rendered[name] { + extra = append(extra, name) + } + } + sort.Strings(extra) + for _, name := range extra { + parts = append(parts, "## "+name+"\n"+sections[name]) + } + + return strings.Join(parts, "\n\n") +} + +// isTestFilePath returns true if the file path appears to be in a test directory. +// Used as a fallback for nodes that may not have the is_test property set. +func isTestFilePath(fp string) bool { + return fp != "" && strings.Contains(fp, "test") +} + +// FindArchitectureDocs discovers existing architecture documentation files in a project. +// Returns file paths matching common architecture doc patterns. +func (s *Store) FindArchitectureDocs(project string) ([]string, error) { + rows, err := s.q.Query(` + SELECT file_path FROM nodes + WHERE project=? AND label='File' + AND ( + file_path LIKE '%ARCHITECTURE.md' OR + file_path LIKE '%ADR.md' OR + file_path LIKE '%DECISIONS.md' OR + file_path LIKE 'docs/adr/%' OR + file_path LIKE 'doc/adr/%' OR + file_path LIKE 'adr/%' + ) + ORDER BY file_path LIMIT 20`, project) + if err != nil { + return nil, err + } + defer rows.Close() + + var docs []string + for rows.Next() { + var fp string + if err := rows.Scan(&fp); err != nil { + return nil, err + } + docs = append(docs, fp) + } + return docs, rows.Err() +} diff --git a/internal/store/architecture_test.go b/internal/store/architecture_test.go new file mode 100644 index 000000000..01751949f --- /dev/null +++ b/internal/store/architecture_test.go @@ -0,0 +1,1048 @@ +package store + +import ( + "strings" + "testing" +) + +// setupArchTestStore creates a store with representative nodes and edges for architecture tests. +func setupArchTestStore(t *testing.T) *Store { + t.Helper() + s, err := OpenMemory() + if err != nil { + t.Fatalf("OpenMemory: %v", err) + } + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatalf("UpsertProject: %v", err) + } + + // Files + for _, fp := range []string{"main.go", "handler.go", "service.go", "model.py", "utils.js"} { + _, _ = s.UpsertNode(&Node{Project: "test", Label: "File", Name: fp, QualifiedName: "test." + fp, FilePath: fp}) + } + + // Packages + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Package", Name: "cmd", QualifiedName: "test.cmd"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Package", Name: "handler", QualifiedName: "test.handler"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Package", Name: "service", QualifiedName: "test.service"}) + + // Functions with different packages (4-segment QNs for realistic sub-package extraction) + idMain, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "main", + QualifiedName: "test.cmd.server.main", FilePath: "cmd/server/main.go", + Properties: map[string]any{"is_entry_point": true}, + }) + idHandleReq, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "HandleRequest", + QualifiedName: "test.internal.handler.HandleRequest", FilePath: "internal/handler/handler.go", + Properties: map[string]any{"is_entry_point": true}, + }) + idProcess, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "ProcessOrder", + QualifiedName: "test.internal.service.ProcessOrder", FilePath: "internal/service/service.go", + }) + idValidate, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "ValidateOrder", + QualifiedName: "test.internal.service.ValidateOrder", FilePath: "internal/service/service.go", + }) + idHelper, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "formatDate", + QualifiedName: "test.internal.service.formatDate", FilePath: "internal/service/service.go", + }) + + // Test function (should be excluded from entry_points/hotspots) + idTestFunc, _ := s.UpsertNode(&Node{ + Project: "test", Label: "Function", Name: "TestHandleRequest", + QualifiedName: "test.internal.handler.handler_test.TestHandleRequest", + FilePath: "internal/handler/handler_test.go", + Properties: map[string]any{"is_entry_point": true}, + }) + + // Route + _, _ = s.UpsertNode(&Node{ + Project: "test", Label: "Route", Name: "/api/orders", + QualifiedName: "test.internal.handler.route./api/orders", + Properties: map[string]any{"method": "POST", "path": "/api/orders", "handler": "HandleRequest"}, + }) + + // Edges: main → HandleRequest → ProcessOrder → ValidateOrder + // ProcessOrder → formatDate + _, _ = s.InsertEdge(&Edge{Project: "test", SourceID: idMain, TargetID: idHandleReq, Type: "CALLS"}) + _, _ = s.InsertEdge(&Edge{Project: "test", SourceID: idHandleReq, TargetID: idProcess, Type: "CALLS"}) + _, _ = s.InsertEdge(&Edge{Project: "test", SourceID: idProcess, TargetID: idValidate, Type: "CALLS"}) + _, _ = s.InsertEdge(&Edge{Project: "test", SourceID: idProcess, TargetID: idHelper, Type: "CALLS"}) + _, _ = s.InsertEdge(&Edge{Project: "test", SourceID: idTestFunc, TargetID: idHandleReq, Type: "CALLS"}) + + return s +} + +func TestGetArchitectureAll(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + info, err := s.GetArchitecture("test", nil) // nil = all aspects + if err != nil { + t.Fatalf("GetArchitecture: %v", err) + } + + if len(info.Languages) == 0 { + t.Error("expected languages to be populated") + } + if len(info.Packages) == 0 { + t.Error("expected packages to be populated") + } + if len(info.EntryPoints) == 0 { + t.Error("expected entry_points to be populated") + } + if len(info.Routes) == 0 { + t.Error("expected routes to be populated") + } + if len(info.Hotspots) == 0 { + t.Error("expected hotspots to be populated") + } + if len(info.Boundaries) == 0 { + t.Error("expected boundaries to be populated") + } +} + +func TestArchEntryPointsExcludeTests(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + eps, err := s.archEntryPoints("test") + if err != nil { + t.Fatal(err) + } + + for _, ep := range eps { + if strings.Contains(ep.File, "test") { + t.Errorf("test function leaked into entry_points: %s (%s)", ep.Name, ep.File) + } + } + if len(eps) != 2 { + t.Errorf("expected 2 entry points (main, HandleRequest), got %d", len(eps)) + } +} + +func TestArchHotspotsExcludeTests(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + hotspots, err := s.archHotspots("test") + if err != nil { + t.Fatal(err) + } + + for _, h := range hotspots { + if strings.Contains(h.Name, "Test") { + t.Errorf("test function leaked into hotspots: %s", h.Name) + } + } +} + +func TestGetArchitectureSpecificAspects(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + info, err := s.GetArchitecture("test", []string{"languages", "hotspots"}) + if err != nil { + t.Fatalf("GetArchitecture: %v", err) + } + + if len(info.Languages) == 0 { + t.Error("expected languages populated") + } + if len(info.Hotspots) == 0 { + t.Error("expected hotspots populated") + } + + // These should be nil because not requested + if info.Packages != nil { + t.Error("expected packages to be nil") + } + if info.EntryPoints != nil { + t.Error("expected entry_points to be nil") + } + if info.Routes != nil { + t.Error("expected routes to be nil") + } +} + +func TestGetArchitectureEmpty(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("empty", "/tmp/empty"); err != nil { + t.Fatal(err) + } + + info, err := s.GetArchitecture("empty", []string{"all"}) + if err != nil { + t.Fatalf("GetArchitecture: %v", err) + } + + // All should be empty/nil but no errors + if info == nil { + t.Fatal("expected non-nil ArchitectureInfo") + } +} + +func TestArchLanguages(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + langs, err := s.archLanguages("test") + if err != nil { + t.Fatal(err) + } + + langMap := map[string]int{} + for _, l := range langs { + langMap[l.Language] = l.FileCount + } + + if langMap["Go"] != 3 { + t.Errorf("expected 3 Go files, got %d", langMap["Go"]) + } + if langMap["Python"] != 1 { + t.Errorf("expected 1 Python file, got %d", langMap["Python"]) + } + if langMap["JavaScript"] != 1 { + t.Errorf("expected 1 JavaScript file, got %d", langMap["JavaScript"]) + } +} + +func TestArchLayers(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + layers, err := s.archLayers("test") + if err != nil { + t.Fatal(err) + } + + if len(layers) == 0 { + t.Fatal("expected layer classifications") + } + + layerMap := map[string]string{} + for _, l := range layers { + layerMap[l.Name] = l.Layer + } + + // Service package has high fan-in from handler, should be core or internal + // Handler package has routes, should be api + if layerMap["handler"] != "api" { + t.Logf("handler layer: %q (expected api)", layerMap["handler"]) + } +} + +func TestArchClusters(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + clusters, err := s.archClusters("test") + if err != nil { + t.Fatal(err) + } + + // With 5 functions and 4 edges, Louvain should find at least 1 cluster + if clusters == nil { + t.Log("clusters returned nil — may need more nodes for meaningful clustering") + return + } + + for _, c := range clusters { + if c.Members < 2 { + t.Errorf("cluster %d has only %d members, expected >= 2", c.ID, c.Members) + } + if c.Label == "" { + t.Errorf("cluster %d has empty label", c.ID) + } + } +} + +func TestArchFileTree(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + tree, err := s.archFileTree("test") + if err != nil { + t.Fatal(err) + } + + if len(tree) == 0 { + t.Fatal("expected file tree entries") + } + + // Check that entries have valid types + for _, entry := range tree { + if entry.Type != "dir" && entry.Type != "file" { + t.Errorf("unexpected type %q for %s", entry.Type, entry.Path) + } + } +} + +func TestArchRoutes(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + routes, err := s.archRoutes("test") + if err != nil { + t.Fatal(err) + } + + if len(routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes)) + } + if routes[0].Method != "POST" { + t.Errorf("expected POST, got %s", routes[0].Method) + } + if routes[0].Path != "/api/orders" { + t.Errorf("expected /api/orders, got %s", routes[0].Path) + } + if routes[0].Handler != "HandleRequest" { + t.Errorf("expected HandleRequest, got %s", routes[0].Handler) + } +} + +func TestArchHotspots(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + hotspots, err := s.archHotspots("test") + if err != nil { + t.Fatal(err) + } + + if len(hotspots) == 0 { + t.Fatal("expected hotspots") + } + + // ProcessOrder should be a hotspot (called by HandleRequest) + found := false + for _, h := range hotspots { + if h.Name == "ProcessOrder" { + found = true + if h.FanIn < 1 { + t.Errorf("ProcessOrder fan-in: %d, expected >= 1", h.FanIn) + } + } + } + if !found { + t.Log("ProcessOrder not in hotspots — may be expected with few edges") + } +} + +func TestArchBoundaries(t *testing.T) { + s := setupArchTestStore(t) + defer s.Close() + + boundaries, err := s.archBoundaries("test") + if err != nil { + t.Fatal(err) + } + + if len(boundaries) == 0 { + t.Fatal("expected cross-package boundaries") + } + + // server → handler and handler → service should be present + foundServerHandler := false + foundHandlerService := false + for _, b := range boundaries { + if b.From == "server" && b.To == "handler" { + foundServerHandler = true + } + if b.From == "handler" && b.To == "service" { + foundHandlerService = true + } + } + if !foundServerHandler { + t.Error("missing server → handler boundary") + } + if !foundHandlerService { + t.Error("missing handler → service boundary") + } +} + +// --- Case-insensitive search tests --- + +func TestSearchCaseInsensitiveDefault(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "FooBar", QualifiedName: "test.FooBar"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "foobar", QualifiedName: "test.foobar"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "FOOBAR", QualifiedName: "test.FOOBAR"}) + + // Default (CaseSensitive=false) should match all 3 + output, err := s.Search(&SearchParams{ + Project: "test", + NamePattern: "foobar", + MinDegree: -1, + MaxDegree: -1, + }) + if err != nil { + t.Fatal(err) + } + if len(output.Results) != 3 { + t.Errorf("case-insensitive default: expected 3 matches, got %d", len(output.Results)) + } +} + +func TestSearchCaseSensitiveExplicit(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "FooBar", QualifiedName: "test.FooBar"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "foobar", QualifiedName: "test.foobar"}) + _, _ = s.UpsertNode(&Node{Project: "test", Label: "Function", Name: "FOOBAR", QualifiedName: "test.FOOBAR"}) + + // Explicit case-sensitive should match only exact case + output, err := s.Search(&SearchParams{ + Project: "test", + NamePattern: "foobar", + CaseSensitive: true, + MinDegree: -1, + MaxDegree: -1, + }) + if err != nil { + t.Fatal(err) + } + if len(output.Results) != 1 { + t.Errorf("case-sensitive: expected 1 match, got %d", len(output.Results)) + } + if len(output.Results) > 0 && output.Results[0].Node.Name != "foobar" { + t.Errorf("case-sensitive: expected 'foobar', got %q", output.Results[0].Node.Name) + } +} + +func TestEnsureCaseInsensitive(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"handler", "(?i)handler"}, + {"(?i)handler", "(?i)handler"}, // idempotent + {".*Order.*", "(?i).*Order.*"}, + {"", "(?i)"}, + } + for _, tt := range tests { + got := ensureCaseInsensitive(tt.input) + if got != tt.want { + t.Errorf("ensureCaseInsensitive(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestStripCaseFlag(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"(?i)handler", "handler"}, + {"handler", "handler"}, + {"(?i)(?i)double", "(?i)double"}, + } + for _, tt := range tests { + got := stripCaseFlag(tt.input) + if got != tt.want { + t.Errorf("stripCaseFlag(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// --- ADR tests --- + +func TestStoreAndRetrieveADR(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + content := "## PURPOSE\nTest project for unit tests.\n\n## STACK\n- Go: speed" + if err := s.StoreADR("test", content); err != nil { + t.Fatal(err) + } + + adr, err := s.GetADR("test") + if err != nil { + t.Fatal(err) + } + + if adr.Content != content { + t.Errorf("content mismatch: %q", adr.Content) + } + if adr.Project != "test" { + t.Errorf("project mismatch: %q", adr.Project) + } + if adr.CreatedAt == "" { + t.Error("created_at empty") + } + if adr.UpdatedAt == "" { + t.Error("updated_at empty") + } +} + +func TestStoreADRUpsert(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + if err := s.StoreADR("test", "v1"); err != nil { + t.Fatal(err) + } + if err := s.StoreADR("test", "v2"); err != nil { + t.Fatal(err) + } + + adr, err := s.GetADR("test") + if err != nil { + t.Fatal(err) + } + if adr.Content != "v2" { + t.Errorf("expected v2, got %q", adr.Content) + } +} + +func TestDeleteADR(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + if err := s.StoreADR("test", "## PURPOSE\nTest"); err != nil { + t.Fatal(err) + } + + if err := s.DeleteADR("test"); err != nil { + t.Fatal(err) + } + + _, err = s.GetADR("test") + if err == nil { + t.Error("expected error after delete, got nil") + } +} + +func TestDeleteADRNotFound(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + err = s.DeleteADR("nonexistent") + if err == nil { + t.Error("expected error for nonexistent ADR") + } +} + +func TestParseADRSections(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + }{ + { + name: "basic two sections", + input: "## PURPOSE\nFoo\n\n## STACK\nBar", + want: map[string]string{"PURPOSE": "Foo", "STACK": "Bar"}, + }, + { + name: "all six sections", + input: "## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF", + want: map[string]string{"PURPOSE": "A", "STACK": "B", "ARCHITECTURE": "C", "PATTERNS": "D", "TRADEOFFS": "E", "PHILOSOPHY": "F"}, + }, + { + name: "non-canonical header preserved as text", + input: "## PURPOSE\nFoo\n## CUSTOM\nStill in PURPOSE\n\n## STACK\nBar", + want: map[string]string{"PURPOSE": "Foo\n## CUSTOM\nStill in PURPOSE", "STACK": "Bar"}, + }, + { + name: "empty content", + input: "", + want: map[string]string{}, + }, + { + name: "content before first section", + input: "preamble\n## PURPOSE\nFoo", + want: map[string]string{"PURPOSE": "Foo"}, + }, + { + name: "multiline section content", + input: "## PURPOSE\nLine 1\nLine 2\nLine 3\n\n## STACK\n- Go\n- SQLite", + want: map[string]string{"PURPOSE": "Line 1\nLine 2\nLine 3", "STACK": "- Go\n- SQLite"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseADRSections(tt.input) + if len(got) != len(tt.want) { + t.Errorf("section count: got %d, want %d\ngot: %v", len(got), len(tt.want), got) + return + } + for k, wantV := range tt.want { + if gotV, ok := got[k]; !ok { + t.Errorf("missing section %q", k) + } else if gotV != wantV { + t.Errorf("section %q:\n got: %q\n want: %q", k, gotV, wantV) + } + } + }) + } +} + +func TestRenderADR(t *testing.T) { + tests := []struct { + name string + sections map[string]string + want string + }{ + { + name: "canonical order", + sections: map[string]string{"STACK": "Bar", "PURPOSE": "Foo"}, + want: "## PURPOSE\nFoo\n\n## STACK\nBar", + }, + { + name: "all sections in order", + sections: map[string]string{"PHILOSOPHY": "F", "PURPOSE": "A", "STACK": "B", "ARCHITECTURE": "C", "PATTERNS": "D", "TRADEOFFS": "E"}, + want: "## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF", + }, + { + name: "non-canonical sections appended alphabetically", + sections: map[string]string{"PURPOSE": "Foo", "ZEBRA": "Z", "ALPHA": "A"}, + want: "## PURPOSE\nFoo\n\n## ALPHA\nA\n\n## ZEBRA\nZ", + }, + { + name: "empty map", + sections: map[string]string{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RenderADR(tt.sections) + if got != tt.want { + t.Errorf("RenderADR:\n got: %q\n want: %q", got, tt.want) + } + }) + } +} + +func TestParseRenderRoundTrip(t *testing.T) { + original := "## PURPOSE\nTest project\n\n## STACK\n- Go: speed\n- SQLite: embedded\n\n## ARCHITECTURE\nPipeline pattern\n\n## PATTERNS\n- Convention over config\n\n## TRADEOFFS\n- Speed over features\n\n## PHILOSOPHY\n- Keep it simple" + sections := ParseADRSections(original) + rendered := RenderADR(sections) + if rendered != original { + t.Errorf("round-trip mismatch:\n got: %q\n want: %q", rendered, original) + } +} + +func TestUpdateADRSections(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + // Store initial ADR + initial := "## PURPOSE\nOriginal purpose\n\n## STACK\n- Go" + if err := s.StoreADR("test", initial); err != nil { + t.Fatal(err) + } + + // Update only PATTERNS section + updated, err := s.UpdateADRSections("test", map[string]string{ + "PATTERNS": "- Pipeline pattern", + }) + if err != nil { + t.Fatal(err) + } + + // Verify all sections are present + sections := ParseADRSections(updated.Content) + if sections["PURPOSE"] != "Original purpose" { + t.Errorf("PURPOSE changed: %q", sections["PURPOSE"]) + } + if sections["STACK"] != "- Go" { + t.Errorf("STACK changed: %q", sections["STACK"]) + } + if sections["PATTERNS"] != "- Pipeline pattern" { + t.Errorf("PATTERNS not updated: %q", sections["PATTERNS"]) + } +} + +func TestUpdateADRSectionsOverflow(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + if err := s.StoreADR("test", "## PURPOSE\nShort"); err != nil { + t.Fatal(err) + } + + // Try to update with content that exceeds the limit + hugeContent := make([]byte, maxADRLength+1) + for i := range hugeContent { + hugeContent[i] = 'x' + } + + _, err = s.UpdateADRSections("test", map[string]string{ + "STACK": string(hugeContent), + }) + if err == nil { + t.Error("expected overflow error") + } +} + +func TestUpdateADRSectionsNoExisting(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + _, err = s.UpdateADRSections("test", map[string]string{ + "PURPOSE": "New purpose", + }) + if err == nil { + t.Error("expected error when no existing ADR") + } +} + +// --- ADR validation tests --- + +func TestValidateADRContentAllSections(t *testing.T) { + content := "## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF" + if err := ValidateADRContent(content); err != nil { + t.Errorf("expected no error for complete ADR, got: %v", err) + } +} + +func TestValidateADRContentMissingSections(t *testing.T) { + content := "## PURPOSE\nA\n\n## STACK\nB" + err := ValidateADRContent(content) + if err == nil { + t.Fatal("expected error for incomplete ADR") + } + // Error should mention the missing sections + for _, missing := range []string{"ARCHITECTURE", "PATTERNS", "TRADEOFFS", "PHILOSOPHY"} { + if !strings.Contains(err.Error(), missing) { + t.Errorf("error should mention missing section %q: %v", missing, err) + } + } +} + +func TestValidateADRContentEmpty(t *testing.T) { + err := ValidateADRContent("") + if err == nil { + t.Fatal("expected error for empty content") + } +} + +func TestValidateADRSectionKeysValid(t *testing.T) { + sections := map[string]string{ + "PURPOSE": "updated", + "STACK": "updated", + } + if err := ValidateADRSectionKeys(sections); err != nil { + t.Errorf("expected no error for valid keys, got: %v", err) + } +} + +func TestValidateADRSectionKeysInvalid(t *testing.T) { + sections := map[string]string{ + "PURPOSE": "ok", + "STACKS": "typo", + "CUSTOM": "invalid", + } + err := ValidateADRSectionKeys(sections) + if err == nil { + t.Fatal("expected error for invalid keys") + } + if !strings.Contains(err.Error(), "STACKS") { + t.Errorf("error should mention invalid key STACKS: %v", err) + } + if !strings.Contains(err.Error(), "CUSTOM") { + t.Errorf("error should mention invalid key CUSTOM: %v", err) + } +} + +func TestValidateADRSectionKeysEmpty(t *testing.T) { + if err := ValidateADRSectionKeys(map[string]string{}); err != nil { + t.Errorf("expected no error for empty map, got: %v", err) + } +} + +// --- Louvain algorithm tests --- + +func TestLouvainBasic(t *testing.T) { + // Triangle: A-B, B-C, A-C (should form one community) + // Isolated: D-E (should form another) + nodes := []int64{1, 2, 3, 4, 5} + edges := []louvainEdge{ + {src: 1, dst: 2}, + {src: 2, dst: 3}, + {src: 1, dst: 3}, + {src: 4, dst: 5}, + } + + partition := louvain(nodes, edges) + + // A, B, C should be in the same community + if partition[1] != partition[2] || partition[2] != partition[3] { + t.Errorf("triangle nodes should be in same community: %v", partition) + } + + // D, E should be in the same community + if partition[4] != partition[5] { + t.Errorf("pair nodes should be in same community: %v", partition) + } + + // Triangle and pair should be in different communities + if partition[1] == partition[4] { + t.Errorf("triangle and pair should be in different communities: %v", partition) + } +} + +func TestLouvainEmpty(t *testing.T) { + partition := louvain(nil, nil) + if len(partition) != 0 { + t.Errorf("expected empty partition, got %v", partition) + } +} + +func TestLouvainSingleNode(t *testing.T) { + partition := louvain([]int64{42}, nil) + if len(partition) != 1 { + t.Fatalf("expected 1 entry, got %d", len(partition)) + } + if _, ok := partition[42]; !ok { + t.Error("expected node 42 in partition") + } +} + +func TestLouvainConverges(t *testing.T) { + // Larger test: two clear clusters connected by a thin bridge + var nodes []int64 + var edges []louvainEdge + + // Cluster 1: nodes 1-10, fully connected + for i := int64(1); i <= 10; i++ { + nodes = append(nodes, i) + for j := i + 1; j <= 10; j++ { + edges = append(edges, louvainEdge{src: i, dst: j}) + } + } + + // Cluster 2: nodes 11-20, fully connected + for i := int64(11); i <= 20; i++ { + nodes = append(nodes, i) + for j := i + 1; j <= 20; j++ { + edges = append(edges, louvainEdge{src: i, dst: j}) + } + } + + // Bridge: single edge between clusters + edges = append(edges, louvainEdge{src: 5, dst: 15}) + + partition := louvain(nodes, edges) + + // Count communities + communities := map[int]int{} + for _, comm := range partition { + communities[comm]++ + } + + // Should find exactly 2 communities (or close to it) + if len(communities) < 2 { + t.Errorf("expected at least 2 communities, got %d", len(communities)) + } + + // Nodes 1-10 should mostly be in the same community + comm1 := partition[int64(1)] + sameCount := 0 + for i := int64(1); i <= 10; i++ { + if partition[i] == comm1 { + sameCount++ + } + } + if sameCount < 8 { + t.Errorf("expected cluster 1 nodes mostly in same community, got %d/10", sameCount) + } +} + +// --- qnToPackage regression tests --- + +func TestQnToPackage(t *testing.T) { + tests := []struct { + qn string + want string + }{ + // 4+ segment QNs — returns segment[2] (sub-package) + {"project.internal.store.search.Search", "store"}, + {"project.src.utils.helper.foo", "utils"}, + {"project.src.components.Button.render", "components"}, + {"project.cmd.server.main", "server"}, + // 3-segment QNs — falls back to segment[1] + {"project.main.foo", "main"}, + {"project.cmd", "cmd"}, + // Edge cases + {"standalone", ""}, + {"", ""}, + } + for _, tt := range tests { + got := qnToPackage(tt.qn) + if got != tt.want { + t.Errorf("qnToPackage(%q) = %q, want %q", tt.qn, got, tt.want) + } + } +} + +func TestQnToTopPackage(t *testing.T) { + tests := []struct { + qn string + want string + }{ + {"project.internal.store.search.Search", "internal"}, + {"project.src.components.Button", "src"}, + {"project.cmd", "cmd"}, + {"standalone", ""}, + } + for _, tt := range tests { + got := qnToTopPackage(tt.qn) + if got != tt.want { + t.Errorf("qnToTopPackage(%q) = %q, want %q", tt.qn, got, tt.want) + } + } +} + +func TestFindArchitectureDocs(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + // Add some file nodes, including architecture docs + for _, fp := range []string{"main.go", "ARCHITECTURE.md", "docs/adr/001-use-sqlite.md", "README.md"} { + _, _ = s.UpsertNode(&Node{Project: "test", Label: "File", Name: fp, QualifiedName: "test." + fp, FilePath: fp}) + } + + docs, err := s.FindArchitectureDocs("test") + if err != nil { + t.Fatal(err) + } + + if len(docs) != 2 { + t.Fatalf("expected 2 architecture docs, got %d: %v", len(docs), docs) + } + + // Should find ARCHITECTURE.md and docs/adr/001-use-sqlite.md + found := map[string]bool{} + for _, d := range docs { + found[d] = true + } + if !found["ARCHITECTURE.md"] { + t.Error("missing ARCHITECTURE.md") + } + if !found["docs/adr/001-use-sqlite.md"] { + t.Error("missing docs/adr/001-use-sqlite.md") + } +} + +func TestFindArchitectureDocsEmpty(t *testing.T) { + s, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.UpsertProject("test", "/tmp/test"); err != nil { + t.Fatal(err) + } + + docs, err := s.FindArchitectureDocs("test") + if err != nil { + t.Fatal(err) + } + if len(docs) != 0 { + t.Errorf("expected 0 docs, got %d", len(docs)) + } +} + +func TestIsTestFilePath(t *testing.T) { + tests := []struct { + fp string + want bool + }{ + {"internal/handler/handler.go", false}, + {"src/__tests__/handler.test.ts", true}, + {"src/test/java/com/example/Test.java", true}, + {"tests/test_handler.py", true}, + {"testdata/fixture.json", true}, + {"", false}, + } + for _, tt := range tests { + got := isTestFilePath(tt.fp) + if got != tt.want { + t.Errorf("isTestFilePath(%q) = %v, want %v", tt.fp, got, tt.want) + } + } +} diff --git a/internal/store/louvain.go b/internal/store/louvain.go new file mode 100644 index 000000000..567d4be19 --- /dev/null +++ b/internal/store/louvain.go @@ -0,0 +1,224 @@ +package store + +import "math/rand" + +// louvainEdge represents an edge for the Louvain algorithm. +type louvainEdge struct { + src int64 + dst int64 + edgeType string // for post-processing only +} + +// louvain implements community detection using the Louvain algorithm. +// Input: node IDs + edges (treated as undirected). +// Output: map[nodeID] → communityID. +func louvain(nodes []int64, edges []louvainEdge) map[int64]int { + const resolution = 1.0 + if len(nodes) == 0 { + return map[int64]int{} + } + + // Build compact index: nodeID → sequential index + idxOf := make(map[int64]int, len(nodes)) + for i, id := range nodes { + idxOf[id] = i + } + n := len(nodes) + + // Build adjacency list (undirected, weighted by edge count) + adj := make([][]int, n) + weight := make([][]float64, n) + totalWeight := 0.0 + + edgeWeight := map[[2]int]float64{} + for _, e := range edges { + si, ok1 := idxOf[e.src] + di, ok2 := idxOf[e.dst] + if !ok1 || !ok2 || si == di { + continue + } + key := [2]int{si, di} + if si > di { + key = [2]int{di, si} + } + edgeWeight[key]++ + } + + for key, w := range edgeWeight { + si, di := key[0], key[1] + adj[si] = append(adj[si], di) + weight[si] = append(weight[si], w) + adj[di] = append(adj[di], si) + weight[di] = append(weight[di], w) + totalWeight += w + } + + if totalWeight == 0 { + // No edges: each node is its own community + result := make(map[int64]int, n) + for i, id := range nodes { + result[id] = i + } + return result + } + + // Initialize: each node in its own community + community := make([]int, n) + for i := range community { + community[i] = i + } + + // Degree (weighted sum for each node) + degree := make([]float64, n) + for i := 0; i < n; i++ { + for _, w := range weight[i] { + degree[i] += w + } + } + + // Main Louvain loop + maxIter := 10 + for iter := 0; iter < maxIter; iter++ { + improved := louvainLocalMoving(n, adj, weight, degree, community, totalWeight, resolution) + louvainRefine(n, adj, weight, degree, community, totalWeight, resolution) + + if !improved { + break + } + } + + // Map back to original IDs + result := make(map[int64]int, n) + for i, id := range nodes { + result[id] = community[i] + } + return result +} + +// louvainLocalMoving greedily moves each node to the community that maximizes modularity gain. +// Returns true if any node was moved. +func louvainLocalMoving(n int, adj [][]int, weight [][]float64, degree []float64, community []int, totalWeight, resolution float64) bool { + improved := false + + // Community total degree + commDegree := make(map[int]float64) + for i := 0; i < n; i++ { + commDegree[community[i]] += degree[i] + } + + // Random order for convergence stability + order := make([]int, n) + for i := range order { + order[i] = i + } + rand.Shuffle(n, func(i, j int) { order[i], order[j] = order[j], order[i] }) + + for _, i := range order { + curComm := community[i] + + // Compute weights to each neighboring community + neighborComm := map[int]float64{} + for j, neighbor := range adj[i] { + nc := community[neighbor] + neighborComm[nc] += weight[i][j] + } + + // Remove node from its community + commDegree[curComm] -= degree[i] + + bestComm := curComm + bestGain := 0.0 + + for comm, wIn := range neighborComm { + // Modularity gain formula + gain := wIn - resolution*degree[i]*commDegree[comm]/(2*totalWeight) + if gain > bestGain { + bestGain = gain + bestComm = comm + } + } + + // Also consider staying in current community + wInCur := neighborComm[curComm] + curGain := wInCur - resolution*degree[i]*commDegree[curComm]/(2*totalWeight) + if curGain >= bestGain { + bestComm = curComm + } + + community[i] = bestComm + commDegree[bestComm] += degree[i] + + if bestComm != curComm { + improved = true + } + } + + return improved +} + +// louvainRefine checks each community for well-connectedness and potentially splits +// poorly-connected ones. +func louvainRefine(n int, adj [][]int, weight [][]float64, degree []float64, community []int, totalWeight, resolution float64) { + commMembers := map[int][]int{} + for i := 0; i < n; i++ { + commMembers[community[i]] = append(commMembers[community[i]], i) + } + for _, members := range commMembers { + if len(members) <= 2 { + continue + } + refineCommunity(members, adj, weight, degree, community, n, totalWeight, resolution) + } +} + +// refineCommunity checks one community for well-connectedness and splits if density is too low. +func refineCommunity(members []int, adj [][]int, weight [][]float64, degree []float64, community []int, n int, totalWeight, resolution float64) { + memberSet := map[int]bool{} + for _, m := range members { + memberSet[m] = true + } + + var internalWeight float64 + for _, m := range members { + for j, neighbor := range adj[m] { + if memberSet[neighbor] { + internalWeight += weight[m][j] + } + } + } + internalWeight /= 2 // each edge counted twice + + maxInternal := float64(len(members)*(len(members)-1)) / 2 + if maxInternal == 0 { + return + } + density := internalWeight / maxInternal + if density >= 0.01 || len(members) <= 5 { + return + } + + nextComm := maxCommunity(community, n) + 1 + for _, m := range members { + var wInternal float64 + for j, neighbor := range adj[m] { + if memberSet[neighbor] { + wInternal += weight[m][j] + } + } + expectedInternal := resolution * degree[m] * (internalWeight * 2 / totalWeight) + if wInternal < expectedInternal*0.5 { + community[m] = nextComm + nextComm++ + } + } +} + +func maxCommunity(community []int, n int) int { + maxVal := 0 + for i := 0; i < n; i++ { + if community[i] > maxVal { + maxVal = community[i] + } + } + return maxVal +} diff --git a/internal/store/search.go b/internal/store/search.go index d6f2871d0..a65e1be42 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -24,6 +24,7 @@ type SearchParams struct { IncludeConnected bool // when true, load connected node names (expensive, off by default) ExcludeLabels []string // labels to exclude from results SortBy string // "relevance" (default), "name", "degree" + CaseSensitive bool // false (zero value) = case-insensitive by default } // SearchResult is a node with edge degree info. @@ -125,7 +126,7 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an // pre-filtering. This drastically reduces the rows Go needs to regex-match. // The full regex is still applied in Go for correctness. if params.NamePattern != "" { - if cond, condArgs := buildLikeHintCondition(params.NamePattern, "n.name"); cond != "" { + if cond, condArgs := buildLikeHintCondition(params.NamePattern, "n.name", params.CaseSensitive); cond != "" { nameHasLikeHints = true conditions = append(conditions, cond) args = append(args, condArgs...) @@ -134,7 +135,7 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an // QN pattern: same LIKE hint optimization but against qualified_name only if params.QNPattern != "" { - if cond, condArgs := buildLikeHintCondition(params.QNPattern, "n.qualified_name"); cond != "" { + if cond, condArgs := buildLikeHintCondition(params.QNPattern, "n.qualified_name", params.CaseSensitive); cond != "" { qnHasLikeHints = true conditions = append(conditions, cond) args = append(args, condArgs...) @@ -146,15 +147,22 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an // buildLikeHintCondition extracts literal substrings from a regex pattern and returns // a SQL condition with LIKE clauses for pre-filtering. Returns empty string if no hints. -func buildLikeHintCondition(pattern, column string) (condition string, args []any) { - hints := extractLikeHints(pattern) +// When caseSensitive is false, appends COLLATE NOCASE to each LIKE clause. +func buildLikeHintCondition(pattern, column string, caseSensitive bool) (condition string, args []any) { + // Strip (?i) prefix before extracting hints — metacharacters confuse extractLikeHints + cleanPattern := stripCaseFlag(pattern) + hints := extractLikeHints(cleanPattern) if len(hints) == 0 { return "", nil } + collate := "" + if !caseSensitive { + collate = " COLLATE NOCASE" + } likeParts := make([]string, 0, len(hints)) for _, hint := range hints { likeVal := "%" + hint + "%" - likeParts = append(likeParts, column+" LIKE ?") + likeParts = append(likeParts, column+" LIKE ?"+collate) args = append(args, likeVal) } return "(" + strings.Join(likeParts, " AND ") + ")", args @@ -214,13 +222,21 @@ func (s *Store) executeSearchQuery(where string, args []any, sqlLimit int) ([]*N func applyPatternFilters(nodes []*Node, params *SearchParams) ([]*Node, error) { var err error if params.NamePattern != "" { - nodes, err = filterByNamePattern(nodes, params.NamePattern) + pattern := params.NamePattern + if !params.CaseSensitive { + pattern = ensureCaseInsensitive(pattern) + } + nodes, err = filterByNamePattern(nodes, pattern) if err != nil { return nil, err } } if params.QNPattern != "" { - nodes, err = filterByQNPattern(nodes, params.QNPattern) + pattern := params.QNPattern + if !params.CaseSensitive { + pattern = ensureCaseInsensitive(pattern) + } + nodes, err = filterByQNPattern(nodes, pattern) if err != nil { return nil, err } @@ -528,6 +544,19 @@ func filterByNamePattern(nodes []*Node, pattern string) ([]*Node, error) { return filtered, nil } +// ensureCaseInsensitive prepends (?i) to a regex pattern if not already present. +func ensureCaseInsensitive(pattern string) string { + if strings.HasPrefix(pattern, "(?i)") { + return pattern + } + return "(?i)" + pattern +} + +// stripCaseFlag removes a leading (?i) prefix from a regex pattern. +func stripCaseFlag(pattern string) string { + return strings.TrimPrefix(pattern, "(?i)") +} + // filterByQNPattern filters nodes by a regex pattern against the qualified name. func filterByQNPattern(nodes []*Node, pattern string) ([]*Node, error) { re, err := regexp.Compile(pattern) diff --git a/internal/store/store.go b/internal/store/store.go index 6e4e106d0..b02280b29 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -237,6 +237,16 @@ func (s *Store) initSchema() error { return err } + // Migration: project_summaries table for ADR storage. + _, _ = s.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS project_summaries ( + project TEXT PRIMARY KEY, + summary TEXT NOT NULL, + source_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`) + // Migration: add url_path generated column to edges table. // Generated columns require SQLite 3.31.0+ (mattn/go-sqlite3 supports this). // We check if the column already exists to make this idempotent. diff --git a/internal/tools/architecture.go b/internal/tools/architecture.go new file mode 100644 index 000000000..c6ddcc613 --- /dev/null +++ b/internal/tools/architecture.go @@ -0,0 +1,326 @@ +package tools + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/DeusData/codebase-memory-mcp/internal/store" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// validArchAspects lists all recognized aspect names. +var validArchAspects = map[string]bool{ + "all": true, "languages": true, "packages": true, "entry_points": true, + "routes": true, "hotspots": true, "boundaries": true, "services": true, + "layers": true, "clusters": true, "file_tree": true, "adr": true, +} + +func (s *Server) handleGetArchitecture(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, err := parseArgs(req) + if err != nil { + return errResult(err.Error()), nil + } + + aspects, validErr := parseAspects(args) + if validErr != nil { + return errResult(validErr.Error()), nil + } + + project := getStringArg(args, "project") + st, err := s.resolveStore(project) + if err != nil { + return errResult(fmt.Sprintf("resolve store: %v", err)), nil + } + + projName := s.resolveProjectName(project) + projects, _ := st.ListProjects() + if len(projects) > 0 { + projName = projects[0].Name + } + + info, err := st.GetArchitecture(projName, aspects) + if err != nil { + return errResult(fmt.Sprintf("architecture: %v", err)), nil + } + + responseData := buildArchResponse(projName, info) + addADRToResponse(responseData, aspects, st, projName) + + s.addIndexStatus(responseData) + result := jsonResult(responseData) + s.addUpdateNotice(result) + return result, nil +} + +// parseAspects extracts and validates the aspects array from tool arguments. +func parseAspects(args map[string]any) ([]string, error) { + rawAspects, ok := args["aspects"] + if !ok { + return []string{"all"}, nil + } + arr, ok := rawAspects.([]any) + if !ok { + return []string{"all"}, nil + } + var aspects []string + for _, a := range arr { + str, ok := a.(string) + if !ok { + continue + } + if !validArchAspects[str] { + return nil, fmt.Errorf("unknown aspect: %q", str) + } + aspects = append(aspects, str) + } + if len(aspects) == 0 { + return []string{"all"}, nil + } + return aspects, nil +} + +// buildArchResponse converts ArchitectureInfo fields into a response map, +// including only non-nil aspects. +func buildArchResponse(projName string, info *store.ArchitectureInfo) map[string]any { + data := map[string]any{"project": projName} + if info.Languages != nil { + data["languages"] = info.Languages + } + if info.Packages != nil { + data["packages"] = info.Packages + } + if info.EntryPoints != nil { + data["entry_points"] = info.EntryPoints + } + if info.Routes != nil { + data["routes"] = info.Routes + } + if info.Hotspots != nil { + data["hotspots"] = info.Hotspots + } + if info.Boundaries != nil { + data["boundaries"] = info.Boundaries + } + if info.Services != nil { + data["services"] = info.Services + } + if info.Layers != nil { + data["layers"] = info.Layers + } + if info.Clusters != nil { + data["clusters"] = info.Clusters + } + if info.FileTree != nil { + data["file_tree"] = info.FileTree + } + return data +} + +// addADRToResponse includes the stored ADR in the response when requested. +func addADRToResponse(data map[string]any, aspects []string, st *store.Store, projName string) { + wantADR := false + for _, a := range aspects { + if a == "adr" || a == "all" { + wantADR = true + break + } + } + if !wantADR { + return + } + adr, getErr := st.GetADR(projName) + if getErr != nil || adr == nil { + data["adr"] = nil + hint := "No ADR yet. Create one with manage_adr(mode='store', content='## PURPOSE\\n...\\n\\n## STACK\\n...'). For guided creation: explore the codebase, enter plan mode, draft collaboratively, then store. Sections: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY." + if docs, err := st.FindArchitectureDocs(projName); err == nil && len(docs) > 0 { + hint += fmt.Sprintf(" Existing architecture docs found: %v — consider reading these first.", docs) + } + data["adr_hint"] = hint + return + } + data["adr"] = map[string]any{ + "text": adr.Content, + "updated_at": adr.UpdatedAt, + } +} + +func (s *Server) handleManageADR(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, err := parseArgs(req) + if err != nil { + return errResult(err.Error()), nil + } + + mode := getStringArg(args, "mode") + if mode == "" { + return errResult("mode is required ('get', 'store', 'update', or 'delete')"), nil + } + + project := getStringArg(args, "project") + st, err := s.resolveStore(project) + if err != nil { + return errResult(fmt.Sprintf("resolve store: %v", err)), nil + } + + projName := s.resolveProjectName(project) + projects, _ := st.ListProjects() + if len(projects) > 0 { + projName = projects[0].Name + } + + switch mode { + case "get": + include := parseStringArray(args, "include") + if err := validateSectionFilter(include); err != nil { + return errResult(err.Error()), nil + } + return s.handleADRGet(st, projName, include) + case "store": + content := getStringArg(args, "content") + return s.handleADRStore(st, projName, content) + case "update": + sections := getMapStringArg(args, "sections") + return s.handleADRUpdate(st, projName, sections) + case "delete": + return s.handleADRDelete(st, projName) + default: + return errResult(fmt.Sprintf("invalid mode: %q (use 'get', 'store', 'update', or 'delete')", mode)), nil + } +} + +func (s *Server) handleADRGet(st *store.Store, projName string, include []string) (*mcp.CallToolResult, error) { + adr, err := st.GetADR(projName) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get ADR: %w", err) + } + hint := "No ADR yet. Create one with manage_adr(mode='store', content='## PURPOSE\\n...\\n\\n## STACK\\n...\\n\\n## ARCHITECTURE\\n...\\n\\n## PATTERNS\\n...\\n\\n## TRADEOFFS\\n...\\n\\n## PHILOSOPHY\\n...'). All 6 sections required: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY." + if docs, findErr := st.FindArchitectureDocs(projName); findErr == nil && len(docs) > 0 { + hint += fmt.Sprintf(" Existing architecture docs found: %v — consider reading these first.", docs) + } + return jsonResult(map[string]any{ + "project": projName, + "adr": nil, + "adr_hint": hint, + }), nil + } + + sections := store.ParseADRSections(adr.Content) + + const alignHint = "If you are drafting or finalizing a plan, validate it against the ADR: " + + "check ARCHITECTURE for structural fit, PATTERNS for convention compliance, " + + "STACK for technology alignment, and PHILOSOPHY for principle adherence. " + + "Flag any conflicts before proceeding." + + // Filter sections if include list is provided. + if len(include) > 0 { + filtered := make(map[string]string, len(include)) + for _, name := range include { + if content, ok := sections[name]; ok { + filtered[name] = content + } + } + return jsonResult(map[string]any{ + "project": projName, + "sections": filtered, + "updated_at": adr.UpdatedAt, + "alignment_hint": alignHint, + }), nil + } + + return jsonResult(map[string]any{ + "project": projName, + "sections": sections, + "text": adr.Content, + "updated_at": adr.UpdatedAt, + "alignment_hint": alignHint, + }), nil +} + +func (s *Server) handleADRStore(st *store.Store, projName, content string) (*mcp.CallToolResult, error) { + if content == "" { + return errResult("content is required for mode='store'"), nil + } + if len(content) > store.MaxADRLength() { + return errResult(fmt.Sprintf("ADR too long (%d chars, max %d)", len(content), store.MaxADRLength())), nil + } + if err := store.ValidateADRContent(content); err != nil { + return errResult(err.Error()), nil + } + if err := st.StoreADR(projName, content); err != nil { + return errResult(fmt.Sprintf("store ADR: %v", err)), nil + } + return jsonResult(map[string]any{ + "status": "stored", + "project": projName, + "updated_at": store.Now(), + }), nil +} + +func (s *Server) handleADRUpdate(st *store.Store, projName string, sections map[string]string) (*mcp.CallToolResult, error) { + if len(sections) == 0 { + return errResult("sections is required for mode='update' — e.g. {\"PURPOSE\": \"...\", \"STACK\": \"...\"}"), nil + } + if err := store.ValidateADRSectionKeys(sections); err != nil { + return errResult(err.Error()), nil + } + adr, err := st.UpdateADRSections(projName, sections) + if err != nil { + return errResult(fmt.Sprintf("update ADR: %v", err)), nil + } + parsed := store.ParseADRSections(adr.Content) + return jsonResult(map[string]any{ + "status": "updated", + "project": projName, + "sections": parsed, + "text": adr.Content, + "updated_at": adr.UpdatedAt, + }), nil +} + +func (s *Server) handleADRDelete(st *store.Store, projName string) (*mcp.CallToolResult, error) { + if err := st.DeleteADR(projName); err != nil { + return errResult(fmt.Sprintf("delete ADR: %v", err)), nil + } + return jsonResult(map[string]any{ + "status": "deleted", + "project": projName, + }), nil +} + +// parseStringArray extracts a string array from tool arguments. +func parseStringArray(args map[string]any, key string) []string { + raw, ok := args[key] + if !ok { + return nil + } + arr, ok := raw.([]any) + if !ok { + return nil + } + var result []string + for _, v := range arr { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result +} + +// validateSectionFilter checks that all names in the include filter are canonical sections. +func validateSectionFilter(include []string) error { + if len(include) == 0 { + return nil + } + return store.ValidateADRSectionKeys(stringsToMap(include)) +} + +// stringsToMap converts a string slice to a map[string]string for key validation. +func stringsToMap(ss []string) map[string]string { + m := make(map[string]string, len(ss)) + for _, s := range ss { + m[s] = "" + } + return m +} diff --git a/internal/tools/code_search.go b/internal/tools/code_search.go index 7c5a5b3bd..f3c464161 100644 --- a/internal/tools/code_search.go +++ b/internal/tools/code_search.go @@ -21,13 +21,14 @@ type codeMatch struct { // searchCodeParams holds parsed parameters for a code search request. type searchCodeParams struct { - pattern string - fileGlob string - maxResults int - offset int - isRegex bool - re *regexp.Regexp - project string + pattern string + fileGlob string + maxResults int + offset int + isRegex bool + caseSensitive bool + re *regexp.Regexp + project string } // parseSearchCodeParams extracts and validates search_code parameters from the request. @@ -38,12 +39,13 @@ func parseSearchCodeParams(req *mcp.CallToolRequest) (*searchCodeParams, *mcp.Ca } p := &searchCodeParams{ - pattern: getStringArg(args, "pattern"), - fileGlob: getStringArg(args, "file_pattern"), - maxResults: getIntArg(args, "max_results", 10), - offset: getIntArg(args, "offset", 0), - isRegex: getBoolArg(args, "regex"), - project: getStringArg(args, "project"), + pattern: getStringArg(args, "pattern"), + fileGlob: getStringArg(args, "file_pattern"), + maxResults: getIntArg(args, "max_results", 10), + offset: getIntArg(args, "offset", 0), + isRegex: getBoolArg(args, "regex"), + caseSensitive: getBoolArg(args, "case_sensitive"), + project: getStringArg(args, "project"), } if p.pattern == "" { @@ -51,10 +53,17 @@ func parseSearchCodeParams(req *mcp.CallToolRequest) (*searchCodeParams, *mcp.Ca } if p.isRegex { - p.re, err = regexp.Compile(p.pattern) + pattern := p.pattern + if !p.caseSensitive && !strings.HasPrefix(pattern, "(?i)") { + pattern = "(?i)" + pattern + } + p.re, err = regexp.Compile(pattern) if err != nil { return nil, errResult(fmt.Sprintf("invalid regex: %v", err)) } + } else if !p.caseSensitive { + // For literal mode, lowercase the pattern; matching done case-insensitively + p.pattern = strings.ToLower(p.pattern) } return p, nil @@ -83,7 +92,7 @@ func (s *Server) handleSearchCode(_ context.Context, req *mcp.CallToolRequest) ( } absPath := filepath.Join(root, relPath) - fileMatches := searchFile(absPath, relPath, params.pattern, params.re, params.isRegex, fetchLimit-len(allMatches)) + fileMatches := searchFile(absPath, relPath, params.pattern, params.re, params.isRegex, params.caseSensitive, fetchLimit-len(allMatches)) allMatches = append(allMatches, fileMatches...) } @@ -155,7 +164,7 @@ func (s *Server) collectSearchFilePaths(fileGlob, project string) []string { return filePaths } -func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex bool, limit int) []codeMatch { +func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex, caseSensitive bool, limit int) []codeMatch { f, err := os.Open(absPath) if err != nil { return nil @@ -172,10 +181,14 @@ func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex boo line := scanner.Text() var found bool - if isRegex { + switch { + case isRegex: found = re.MatchString(line) - } else { + case caseSensitive: found = strings.Contains(line, pattern) + default: + // pattern already lowercased in parseSearchCodeParams + found = strings.Contains(strings.ToLower(line), pattern) } if found { diff --git a/internal/tools/file.go b/internal/tools/file.go index 2b57f8c96..c7781982c 100644 --- a/internal/tools/file.go +++ b/internal/tools/file.go @@ -1,196 +1,6 @@ package tools -import ( - "bufio" - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func (s *Server) handleReadFile(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args, err := parseArgs(req) - if err != nil { - return errResult(err.Error()), nil - } - - filePath := getStringArg(args, "path") - if filePath == "" { - return errResult("path is required"), nil - } - - startLine := getIntArg(args, "start_line", 0) - endLine := getIntArg(args, "end_line", 0) - - // Resolve relative path against project root - absPath := filePath - if !filepath.IsAbs(filePath) { - root, rootErr := s.resolveProjectRoot("") - if rootErr != nil { - return errResult(fmt.Sprintf("resolve root: %v", rootErr)), nil - } - absPath = filepath.Join(root, filePath) - } - - // Check file exists and is not a directory - info, statErr := os.Stat(absPath) - if statErr != nil { - return errResult(fmt.Sprintf("file not found: %s", absPath)), nil - } - if info.IsDir() { - return errResult("path is a directory, use list_directory instead"), nil - } - - // Cap file size at 500KB - if info.Size() > 500*1024 { - return errResult(fmt.Sprintf("file too large (%d bytes, max 500KB). Use start_line/end_line to read a portion", info.Size())), nil - } - - f, err := os.Open(absPath) - if err != nil { - return errResult(fmt.Sprintf("open: %v", err)), nil - } - defer f.Close() - - var lines []string - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 1MB line buffer - lineNum := 0 - for scanner.Scan() { - lineNum++ - if startLine > 0 && lineNum < startLine { - continue - } - if endLine > 0 && lineNum > endLine { - break - } - line := scanner.Text() - if len(line) > 500 { - line = line[:500] + "..." - } - lines = append(lines, fmt.Sprintf("%4d | %s", lineNum, line)) - } - - if err := scanner.Err(); err != nil { - return errResult(fmt.Sprintf("read: %v", err)), nil - } - - result := map[string]any{ - "path": absPath, - "total_lines": lineNum, - "content": strings.Join(lines, "\n"), - } - if startLine > 0 || endLine > 0 { - result["range"] = fmt.Sprintf("%d-%d", startLine, endLine) - } - - return jsonResult(result), nil -} - -func (s *Server) handleListDirectory(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args, err := parseArgs(req) - if err != nil { - return errResult(err.Error()), nil - } - - dirPath := getStringArg(args, "path") - globPattern := getStringArg(args, "pattern") - - // Resolve relative path against project root - absPath := dirPath - if dirPath == "" || !filepath.IsAbs(dirPath) { - project := getStringArg(args, "project") - root, rootErr := s.resolveProjectRoot(project) - if rootErr != nil { - return errResult(fmt.Sprintf("resolve root: %v", rootErr)), nil - } - if dirPath == "" { - absPath = root - } else { - absPath = filepath.Join(root, dirPath) - } - } - - info, _ := os.Stat(absPath) - if info == nil { - return errResult(fmt.Sprintf("path not found: %s", absPath)), nil - } - if !info.IsDir() { - return errResult("path is a file, use read_file instead"), nil - } - - entries, listErr := listEntries(absPath, globPattern) - if listErr != nil { - return errResult(listErr.Error()), nil - } - - return jsonResult(map[string]any{ - "directory": absPath, - "count": len(entries), - "entries": entries, - }), nil -} - -type dirEntry struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size,omitempty"` -} - -func listEntries(absPath, globPattern string) ([]dirEntry, error) { - if globPattern != "" { - return listGlobEntries(absPath, globPattern) - } - return listDirChildren(absPath) -} - -func listGlobEntries(absPath, globPattern string) ([]dirEntry, error) { - matches, globErr := filepath.Glob(filepath.Join(absPath, globPattern)) - if globErr != nil { - return nil, fmt.Errorf("glob: %w", globErr) - } - entries := make([]dirEntry, 0, len(matches)) - for _, m := range matches { - fi, _ := os.Stat(m) - if fi == nil { - continue - } - relPath, _ := filepath.Rel(absPath, m) - e := dirEntry{Name: relPath, Path: m, IsDir: fi.IsDir()} - if !fi.IsDir() { - e.Size = fi.Size() - } - entries = append(entries, e) - } - return entries, nil -} - -func listDirChildren(absPath string) ([]dirEntry, error) { - dirEntries, readErr := os.ReadDir(absPath) - if readErr != nil { - return nil, fmt.Errorf("read dir: %w", readErr) - } - entries := make([]dirEntry, 0, len(dirEntries)) - for _, de := range dirEntries { - if strings.HasPrefix(de.Name(), ".") { - continue - } - fi, _ := de.Info() - if fi == nil { - continue - } - e := dirEntry{Name: de.Name(), Path: filepath.Join(absPath, de.Name()), IsDir: de.IsDir()} - if !de.IsDir() { - e.Size = fi.Size() - } - entries = append(entries, e) - } - return entries, nil -} +import "fmt" // resolveProjectRoot finds a project root path by name or from session. func (s *Server) resolveProjectRoot(project string) (string, error) { diff --git a/internal/tools/index.go b/internal/tools/index.go index 7ccf0a9c7..5409b91b4 100644 --- a/internal/tools/index.go +++ b/internal/tools/index.go @@ -63,10 +63,19 @@ func (s *Server) handleIndexRepository(ctx context.Context, req *mcp.CallToolReq indexedAt = proj.IndexedAt } - return jsonResult(map[string]any{ + result := map[string]any{ "project": projectName, "nodes": nodeCount, "edges": edgeCount, "indexed_at": indexedAt, - }), nil + } + + // Check for ADR presence and suggest creation if missing + adr, _ := st.GetADR(projectName) + result["adr_present"] = adr != nil + if adr == nil { + result["adr_hint"] = "Project indexed. Consider creating an Architecture Decision Record: explore the codebase with get_architecture(aspects=['all']), then use manage_adr(mode='store') to persist architectural insights across sessions." + } + + return jsonResult(result), nil } diff --git a/internal/tools/projects.go b/internal/tools/projects.go index fe61a2c35..52f8550f2 100644 --- a/internal/tools/projects.go +++ b/internal/tools/projects.go @@ -22,6 +22,7 @@ func (s *Server) handleListProjects(_ context.Context, _ *mcp.CallToolRequest) ( Nodes int `json:"nodes"` Edges int `json:"edges"` DBPath string `json:"db_path"` + ADRPresent bool `json:"adr_present"` IsSessionProject bool `json:"is_session_project,omitempty"` } @@ -43,13 +44,16 @@ func (s *Server) handleListProjects(_ context.Context, _ *mcp.CallToolRequest) ( rootPath = proj.RootPath } + adr, _ := st.GetADR(info.Name) + entry := projectEntry{ - Name: info.Name, - RootPath: rootPath, - IndexedAt: indexedAt, - Nodes: nc, - Edges: ec, - DBPath: info.DBPath, + Name: info.Name, + RootPath: rootPath, + IndexedAt: indexedAt, + Nodes: nc, + Edges: ec, + DBPath: info.DBPath, + ADRPresent: adr != nil, } if info.Name == s.sessionProject { entry.IsSessionProject = true diff --git a/internal/tools/schema.go b/internal/tools/schema.go index 172706a50..c769544fb 100644 --- a/internal/tools/schema.go +++ b/internal/tools/schema.go @@ -32,11 +32,14 @@ func (s *Server) handleGetGraphSchema(_ context.Context, req *mcp.CallToolReques } type projectSchema struct { - Project string `json:"project"` - Schema *store.SchemaInfo `json:"schema"` + Project string `json:"project"` + Schema *store.SchemaInfo `json:"schema"` + ADRPresent bool `json:"adr_present"` } + adr, _ := st.GetADR(projName) + return jsonResult(map[string]any{ - "projects": []projectSchema{{Project: projName, Schema: schema}}, + "projects": []projectSchema{{Project: projName, Schema: schema, ADRPresent: adr != nil}}, }), nil } diff --git a/internal/tools/search.go b/internal/tools/search.go index 1b5ca50ad..980a4f4c6 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -27,6 +27,7 @@ func (s *Server) handleSearchGraph(_ context.Context, req *mcp.CallToolRequest) Offset: getIntArg(args, "offset", 0), ExcludeEntryPoints: getBoolArg(args, "exclude_entry_points"), IncludeConnected: getBoolArg(args, "include_connected"), + CaseSensitive: getBoolArg(args, "case_sensitive"), } // Parse exclude_labels array; default to excluding Community nodes diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 02db97d3e..155cc163a 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -366,10 +366,65 @@ func (s *Server) ToolNames() []string { func (s *Server) registerTools() { s.registerGraphTools() - s.registerFileTools() s.registerProjectTools() s.registerTraceTools() s.registerDetectChanges() + s.registerArchitectureTools() +} + +func (s *Server) registerArchitectureTools() { + s.addTool(&mcp.Tool{ + Name: "get_architecture", + Description: "Get codebase architecture overview. Returns structural analysis computed from the code graph. Call with aspects=['all'] for full orientation or select specific aspects for targeted queries. Available aspects: languages (language breakdown), packages (top packages with fan-in/out), entry_points (main/init functions), routes (HTTP endpoints with handlers), hotspots (most-called functions), boundaries (cross-package call volumes), services (cross-service HTTP/async links), layers (package-level layer classification — heuristic), clusters (community detection via Louvain algorithm on CALLS + HTTP_CALLS + ASYNC_CALLS — reveals hidden functional modules across packages and services), file_tree (condensed directory structure), adr (stored Architecture Decision Record — use manage_adr to create/update). Recommended: call this first when exploring an unfamiliar codebase.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "aspects": { + "type": "array", + "items": {"type": "string", "enum": ["all", "languages", "packages", "entry_points", "routes", "hotspots", "boundaries", "services", "layers", "clusters", "file_tree", "adr"]}, + "description": "Which architecture aspects to return. Default: ['all']. Use specific aspects to reduce output: ['languages', 'packages'] for quick orientation, ['hotspots', 'boundaries'] for dependency analysis, ['clusters'] for community detection across CALLS/HTTP/ASYNC edges." + }, + "project": { + "type": "string", + "description": "Project to analyze. Defaults to session project." + } + } + }`), + }, s.handleGetArchitecture) + + s.addTool(&mcp.Tool{ + Name: "manage_adr", + Description: "Manage the Architecture Decision Record (ADR) for a project. CRUD operations for a persistent, section-based architectural summary. Modes: get (retrieve full ADR or specific sections via include filter), store (create or fully replace — all 6 sections required), update (patch specific sections — only canonical keys accepted, unmentioned sections preserved), delete (remove ADR). Fixed sections in canonical order: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. Max 8000 chars total. Validation: store rejects content missing any section; update rejects non-canonical keys. Use include=['STACK','PATTERNS'] with get to fetch only needed sections (saves tokens). PLAN ALIGNMENT: Before finalizing any implementation plan, fetch the ADR and validate the plan against it — check ARCHITECTURE for structural fit, PATTERNS for convention compliance, STACK for technology alignment, PHILOSOPHY for principle adherence. Flag conflicts before proceeding. For creating/replacing: explore the codebase first using get_architecture and graph tools, then enter plan mode to draft the ADR collaboratively with the user. Store only after user approval.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["get", "store", "update", "delete"], + "description": "Operation: 'get' retrieves ADR, 'store' creates/replaces (all 6 sections required), 'update' patches sections (canonical keys only), 'delete' removes." + }, + "project": { + "type": "string", + "description": "Project name. Defaults to session project." + }, + "content": { + "type": "string", + "description": "Full ADR markdown (required for mode='store'). Must contain all 6 ## SECTION headers: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. Missing sections will be rejected." + }, + "sections": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Section updates (required for mode='update'). Keys must be canonical section names (PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY). Non-canonical keys are rejected. Values are new content. Unmentioned sections preserved." + }, + "include": { + "type": "array", + "items": {"type": "string", "enum": ["PURPOSE", "STACK", "ARCHITECTURE", "PATTERNS", "TRADEOFFS", "PHILOSOPHY"]}, + "description": "Section filter for mode='get'. Returns only the listed sections instead of the full ADR. Example: ['STACK', 'PATTERNS'] returns ~800 chars instead of ~8000. Omit to get all sections." + } + }, + "required": ["mode"] + }`), + }, s.handleManageADR) } // registerGraphTools registers tools for graph querying, searching, and tracing. @@ -466,7 +521,7 @@ func (s *Server) registerSchemaAndSnippetTools() { func (s *Server) registerSearchTools() { s.addTool(&mcp.Tool{ Name: "search_graph", - Description: "Search the code knowledge graph for functions, classes, modules, routes, and other code elements. Returns nodes matching the criteria with their connectivity (in/out degree). Results are sorted by relevance by default (exact match first, prefix match second, then by connectivity). Community nodes are excluded by default. Pass exclude_labels: [] to include them. Best practice: Chain with trace_call_path and get_code_snippet for complete answers. Example workflow: search_graph(name_pattern='.*Order.*') → trace_call_path(function_name='processOrder') → get_code_snippet(qualified_name='...'). Returns 10 results per page (use offset to paginate, has_more indicates more pages). name_pattern and qn_pattern support full Go regex — one precise regex replaces multiple literal searches. See parameter descriptions for patterns. For dead code: use relationship='CALLS', direction='inbound', max_degree=0, exclude_entry_points=true. For fan-out: use relationship='CALLS', direction='outbound', min_degree=N. Route nodes: properties.handler contains the actual handler function name. Prefer this over query_graph for counting — no row cap. IMPORTANT: The 'relationship' filter counts how many edges of that type each node has (degree filtering) — it does NOT return the actual edges. To list cross-service HTTP_CALLS or ASYNC_CALLS edges with their properties, use query_graph with Cypher instead. Relationship types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.", + Description: "Search the code knowledge graph for functions, classes, modules, routes, and other code elements. Search is case-insensitive by default (set case_sensitive=true for exact case). Best practice: use regex alternatives for broad matching — include abbreviations ('handler|hdlr|ctrl'), word forms ('sponsor|sponsoring|sponsored'), and synonyms ('delete|remove|drop'). One broad regex replaces multiple narrow searches. Returns nodes matching the criteria with their connectivity (in/out degree). Results are sorted by relevance by default (exact match first, prefix match second, then by connectivity). Community nodes are excluded by default. Pass exclude_labels: [] to include them. Best practice: Chain with trace_call_path and get_code_snippet for complete answers. Example workflow: search_graph(name_pattern='.*Order.*') → trace_call_path(function_name='processOrder') → get_code_snippet(qualified_name='...'). Returns 10 results per page (use offset to paginate, has_more indicates more pages). name_pattern and qn_pattern support full Go regex — one precise regex replaces multiple literal searches. See parameter descriptions for patterns. For dead code: use relationship='CALLS', direction='inbound', max_degree=0, exclude_entry_points=true. For fan-out: use relationship='CALLS', direction='outbound', min_degree=N. Route nodes: properties.handler contains the actual handler function name. Prefer this over query_graph for counting — no row cap. IMPORTANT: The 'relationship' filter counts how many edges of that type each node has (degree filtering) — it does NOT return the actual edges. To list cross-service HTTP_CALLS or ASYNC_CALLS edges with their properties, use query_graph with Cypher instead. Relationship types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { @@ -480,11 +535,11 @@ func (s *Server) registerSearchTools() { }, "name_pattern": { "type": "string", - "description": "Regex pattern matched against the short node name only. Supports full Go regex: '.*Handler$' (suffix), '(?i)auth' (case-insensitive), 'get|set|delete' (alternatives), '^on[A-Z]' (prefix+char class). Does NOT match against file paths or qualified names — use qn_pattern for path scoping." + "description": "Regex pattern matched against the short node name. Case-insensitive by default. Supports full Go regex: '.*Handler$' (suffix), 'get|set|delete' (alternatives — no backslash before pipe), '^on[A-Z]' (prefix+char class). Best practice: include word variations in alternatives — 'auth|authenticate|authorization' (word forms), 'handler|hdlr|ctrl' (abbreviations), 'create|new|init' (synonyms). One regex with | replaces multiple separate searches." }, "qn_pattern": { "type": "string", - "description": "Regex pattern matched against the qualified name (full module path). Use to scope searches to directories/modules: '.*services\\.order\\..*' (order service), '.*tests\\..*' (test files only), '(?i).*controller.*\\.handle.*' (handler methods in controllers). Combine with name_pattern for precise cross-cutting queries." + "description": "Regex pattern matched against the qualified name (full module path). Case-insensitive by default. Use to scope searches to directories/modules: '.*services\\.order\\..*' (order service), '.*tests\\..*' (test files only), '.*controller.*\\.handle.*' (handler methods in controllers). Combine with name_pattern for precise cross-cutting queries." }, "file_pattern": { "type": "string", @@ -532,6 +587,10 @@ func (s *Server) registerSearchTools() { "type": "string", "enum": ["relevance", "name", "degree"], "description": "Sort order. Default: relevance (exact match first, prefix match second, then by connectivity)" + }, + "case_sensitive": { + "type": "boolean", + "description": "Match patterns case-sensitively. Default: false (case-insensitive). Set true for exact case matching." } } }`), @@ -539,13 +598,13 @@ func (s *Server) registerSearchTools() { s.addTool(&mcp.Tool{ Name: "search_code", - Description: "Search for text in source code files (like grep, scoped to indexed project). Returns matching lines with file path, line number, and context. Returns 10 matches per page — use offset to paginate, has_more indicates more pages. Use for: string literals, error messages, TODO comments, config values, import statements. With regex=true, supports full Go regex — see pattern parameter for examples. Prefer search_graph for finding functions/classes by name — search_code is for text content that isn't in the graph.", + Description: "Search for text in source code files (like grep, scoped to indexed project). Search is case-insensitive by default (set case_sensitive=true for exact case). With regex=true, use alternatives for broad matching: 'TODO|FIXME|HACK|WORKAROUND' (issue markers), 'sponsor|sponsoring|sponsored' (word forms), 'import|require|include' (cross-language patterns). Returns matching lines with file path, line number, and context. Returns 10 matches per page — use offset to paginate, has_more indicates more pages. Use for: string literals, error messages, TODO comments, config values, import statements. Prefer search_graph for finding functions/classes by name — search_code is for text content that isn't in the graph.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "pattern": { "type": "string", - "description": "Text to search for. Literal string by default; with regex=true supports full Go regex (e.g. 'err != nil', 'TODO|FIXME', '(?i)deprecated')" + "description": "Text to search for. Case-insensitive by default. Literal string match unless regex=true. With regex=true: Go regex syntax (no backslash before pipe). Best practice: use alternatives for word form variance — 'deprecat|obsolete|legacy' catches 'deprecated', 'deprecating', 'obsolete', etc. A partial stem with alternatives is more effective than an exact word." }, "file_pattern": { "type": "string", @@ -563,6 +622,10 @@ func (s *Server) registerSearchTools() { "type": "integer", "description": "Skip N matches for pagination (default: 0). Check has_more in response." }, + "case_sensitive": { + "type": "boolean", + "description": "Match case-sensitively. Default: false (case-insensitive). Set true for exact case matching." + }, "project": { "type": "string", "description": "Project to search in. Defaults to session project." @@ -576,7 +639,7 @@ func (s *Server) registerSearchTools() { func (s *Server) registerQueryTool() { s.addTool(&mcp.Tool{ Name: "query_graph", - Description: "Execute a Cypher-like graph query. WARNING: 200-row cap applies BEFORE aggregation — COUNT queries on large codebases silently undercount. For fan-out/fan-in counting, use search_graph with min_degree/max_degree instead. Best for: relationship patterns, filtered joins, path queries, and edge property filtering. Supports WHERE on edge properties: r.url_path CONTAINS 'orders', r.confidence >= 0.6, r.method = 'POST', r.confidence_band = 'high', r.validated_by_trace = true, r.coupling_score >= 0.5. This is the correct tool for listing cross-service edges — use MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence, r.confidence_band to see HTTP links with URLs and confidence scores (bands: high>=0.7, medium>=0.45, speculative>=0.25), or MATCH (a)-[r:ASYNC_CALLS]->(b) for async dispatch edges. For change coupling: MATCH (a)-[r:FILE_CHANGES_WITH]->(b) RETURN a.name, b.name, r.coupling_score, r.co_change_count. For interface method overrides: MATCH (s)-[r:OVERRIDE]->(i) to find struct methods implementing interface methods. For read references (callbacks, variable assignments): MATCH (a)-[r:USAGE]->(b). Always use LIMIT. Edge types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.", + Description: "Execute a Cypher-like graph query. String matching in WHERE is case-sensitive by default. For case-insensitive regex: use (?i) flag — WHERE f.name =~ '(?i)handler'. CONTAINS and STARTS WITH are always case-sensitive — use =~ with (?i) for case-insensitive substring matching: WHERE f.name =~ '(?i).*order.*'. Best practice: use regex alternatives for word form variance — WHERE f.name =~ '(?i)sponsor|sponsoring|sponsored'. Tip: prefer search_graph for simple name searches (case-insensitive by default) — use query_graph only when you need Cypher's relationship patterns or edge properties. WARNING: 200-row cap applies BEFORE aggregation — COUNT queries on large codebases silently undercount. For fan-out/fan-in counting, use search_graph with min_degree/max_degree instead. Best for: relationship patterns, filtered joins, path queries, and edge property filtering. Supports WHERE on edge properties: r.url_path CONTAINS 'orders', r.confidence >= 0.6, r.method = 'POST', r.confidence_band = 'high', r.validated_by_trace = true, r.coupling_score >= 0.5. This is the correct tool for listing cross-service edges — use MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence, r.confidence_band to see HTTP links with URLs and confidence scores (bands: high>=0.7, medium>=0.45, speculative>=0.25), or MATCH (a)-[r:ASYNC_CALLS]->(b) for async dispatch edges. For change coupling: MATCH (a)-[r:FILE_CHANGES_WITH]->(b) RETURN a.name, b.name, r.coupling_score, r.co_change_count. For interface method overrides: MATCH (s)-[r:OVERRIDE]->(i) to find struct methods implementing interface methods. For read references (callbacks, variable assignments): MATCH (a)-[r:USAGE]->(b). Always use LIMIT. Edge types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { @@ -594,54 +657,6 @@ func (s *Server) registerQueryTool() { }, s.handleQueryGraph) } -// registerFileTools registers tools for file and directory operations. -func (s *Server) registerFileTools() { - s.addTool(&mcp.Tool{ - Name: "read_file", - Description: "Read any file from the indexed project. Supports line range selection for large files. Use for reading config files (Dockerfile, go.mod, requirements.txt), source code, or any text file.", - InputSchema: json.RawMessage(`{ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path (absolute, or relative to project root)" - }, - "start_line": { - "type": "integer", - "description": "Start reading from this line (1-based, optional)" - }, - "end_line": { - "type": "integer", - "description": "Stop reading at this line (inclusive, optional)" - } - }, - "required": ["path"] - }`), - }, s.handleReadFile) - - s.addTool(&mcp.Tool{ - Name: "list_directory", - Description: "List files and subdirectories in a directory. Supports glob patterns for filtering. Use for exploring project structure.", - InputSchema: json.RawMessage(`{ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory path (absolute, or relative to project root). Empty for project root." - }, - "pattern": { - "type": "string", - "description": "Glob pattern to filter entries (e.g. '*.go', '*.py')" - }, - "project": { - "type": "string", - "description": "Project name to resolve root from. Defaults to session project." - } - } - }`), - }, s.handleListDirectory) -} - // registerProjectTools registers tools for project management. func (s *Server) registerProjectTools() { s.addTool(&mcp.Tool{ @@ -743,6 +758,25 @@ func getIntArg(args map[string]any, key string, defaultVal int) int { return int(f) } +// getMapStringArg extracts a map[string]string argument from parsed args. +func getMapStringArg(args map[string]any, key string) map[string]string { + v, ok := args[key] + if !ok { + return nil + } + m, ok := v.(map[string]any) + if !ok { + return nil + } + result := make(map[string]string, len(m)) + for k, val := range m { + if s, ok := val.(string); ok { + result[k] = s + } + } + return result +} + // getBoolArg extracts a boolean argument from parsed args. func getBoolArg(args map[string]any, key string) bool { v, ok := args[key] diff --git a/internal/tools/trace.go b/internal/tools/trace.go index 72789c2f7..57d24e1af 100644 --- a/internal/tools/trace.go +++ b/internal/tools/trace.go @@ -252,7 +252,7 @@ func (s *Server) findSimilarNodes(name, project string, limit int) []*store.Node } params := &store.SearchParams{ Project: projName, - NamePattern: "(?i)" + regexp.QuoteMeta(name), + NamePattern: regexp.QuoteMeta(name), Limit: limit, MinDegree: -1, MaxDegree: -1,