diff --git a/internal/server/features_test.go b/internal/server/features_test.go index 5c9b07a..0248fa0 100644 --- a/internal/server/features_test.go +++ b/internal/server/features_test.go @@ -150,6 +150,69 @@ func TestASTQueryFormatLocation(t *testing.T) { } } +// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor +// by default (they flood results with third-party code) but still searches them +// when the caller targets one explicitly via paths (the walk root is never skipped). +func TestASTQuerySkipsDependencyDirs(t *testing.T) { + srv, tmpDir := newFeaturesServer(t) + ctx := context.Background() + + deps := map[string]string{ + "node_modules": "NodeModulesDep", + "vendor": "VendorDep", + } + for dir, fn := range deps { + full := filepath.Join(tmpDir, dir) + if err := os.MkdirAll(full, 0o750); err != nil { + t.Fatal(err) + } + src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n" + if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil { + t.Fatal(err) + } + } + + // Default: workspace-root query must skip the dependency dirs. + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "pattern": "func $NAME() string", + "language": "go", + "paths": []interface{}{tmpDir}, + "format": "location", + } + res, err := srv.handleASTQuery(ctx, req) + if err != nil { + t.Fatalf("default query error: %v", err) + } + // location format prints file:line, so assert on paths. + text := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(text, "a.go") { + t.Errorf("default query should find top-level a.go, got:\n%s", text) + } + for dir := range deps { + if strings.Contains(text, dir+string(filepath.Separator)) { + t.Errorf("default query must skip %s, got:\n%s", dir, text) + } + } + + // Explicit target: querying inside node_modules must still find its code. + req2 := mcp.CallToolRequest{} + req2.Params.Arguments = map[string]interface{}{ + "pattern": "func $NAME() string", + "language": "go", + "paths": []interface{}{filepath.Join(tmpDir, "node_modules")}, + "format": "location", + } + res2, err := srv.handleASTQuery(ctx, req2) + if err != nil { + t.Fatalf("explicit-target query error: %v", err) + } + text2 := res2.Content[0].(mcp.TextContent).Text + if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") { + t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2) + } +} + // ---- Feature 3 (ast_query): pagination cursor ---- func TestASTQueryPaginationCursor(t *testing.T) { diff --git a/internal/server/handlers_ast.go b/internal/server/handlers_ast.go index 8865c5f..f629454 100644 --- a/internal/server/handlers_ast.go +++ b/internal/server/handlers_ast.go @@ -131,7 +131,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts [] return nil } if info.IsDir() { - if strings.HasPrefix(info.Name(), ".") { + // Skip hidden and dependency dirs (third-party code that would + // flood results), but never the walk root itself — the caller + // explicitly targeted it, e.g. paths:["vendor/foo"]. + if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) { return filepath.SkipDir } return nil @@ -172,6 +175,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts [] return allResults } +// isDependencyDir reports whether name is a well-known third-party dependency +// directory that ast_query skips by default. These hold vendored/installed code, +// not the caller's source, and parsing them wastes time and floods results with +// irrelevant matches. Target one explicitly via paths to query inside it. +func isDependencyDir(name string) bool { + switch name { + case "node_modules", "vendor", "bower_components": + return true + default: + return false + } +} + // hasAnySuffix reports whether path ends with any of the given suffixes. func hasAnySuffix(path string, suffixes []string) bool { for _, ext := range suffixes { diff --git a/internal/server/help_content.go b/internal/server/help_content.go index 1414609..33ddc7c 100644 --- a/internal/server/help_content.go +++ b/internal/server/help_content.go @@ -96,6 +96,11 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" + "Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" + "For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " + "these ARE applied as filters.\n\n" + + "## Scope\n\n" + + "The walk skips hidden directories (dot-prefixed) and dependency directories " + + "(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " + + "flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " + + "the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" + "## Examples\n\n" + "```json\n" + "// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" + diff --git a/internal/server/server.go b/internal/server/server.go index 72324dc..d6e554a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -226,6 +226,7 @@ func (s *Server) registerTools() { mcp.NewTool("ast_query", mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+ "Matching is keyword/kind-based, not fully structural: argument and return-type constraints in the pattern are not enforced — use name_exact/name_matches/kind_in to filter precisely. "+ + "Hidden dirs and dependency dirs (node_modules, vendor, bower_components) are skipped unless targeted explicitly via paths. "+ "See resource filepuff://help/ast_query for flags and examples."), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("pattern",