diff --git a/README.md b/README.md index ad96450..1728f86 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,10 @@ Example `.codemap/config.json`: { "only": ["rs", "sh", "sql", "toml", "yml"], "exclude": ["docs/reference", "docs/research"], + "guidance": { + "missing_extension_hints": true, + "ignored_extensions": [] + }, "depth": 4, "mode": "auto", "budgets": { @@ -331,6 +335,8 @@ Example `.codemap/config.json`: } ``` +When an MCP file search has no visible results but finds real matches hidden by `only`, Codemap reports the matching paths and suggests which extensions to include. These hints still respect `exclude` and never modify project config. Set `guidance.missing_extension_hints` to `false` to disable all hints, or list extensions in `guidance.ignored_extensions` to suppress only those suggestions. + All fields are optional. CLI flags always override config values. Hook-specific policy fields are optional and bounded by safe defaults. diff --git a/cmd/context.go b/cmd/context.go index 7d7594c..5986dbb 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -307,7 +307,7 @@ func detectLanguagesFromFiles(root string) map[string]bool { // Include subdirectory source files. Reuse the scan result for countSourceFiles too. gitCache := scanner.NewGitIgnoreCache(root) - if files, err := scanner.ScanFiles(root, gitCache, nil, nil); err == nil { + if files, err := scanner.ScanConfiguredFiles(root, gitCache); err == nil { for _, f := range files { addLang(scanner.DetectLanguage(f.Path)) } @@ -353,7 +353,7 @@ func countSourceFiles(root string) int { return count } gitCache := scanner.NewGitIgnoreCache(root) - files, err := scanner.ScanFiles(root, gitCache, nil, nil) + files, err := scanner.ScanConfiguredFiles(root, gitCache) if err != nil { return 0 } diff --git a/cmd/context_test.go b/cmd/context_test.go index 5d41354..b9f8b6e 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "reflect" "testing" ) @@ -41,6 +42,25 @@ func TestDetectLanguagesFromFiles_SubdirectorySources(t *testing.T) { } } +func TestBuildContextEnvelopeRespectsConfiguredFilters(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, ".codemap", "config.json"), `{"only":["go"],"exclude":["generated"]}`) + mustWriteFile(t, filepath.Join(root, "main.go"), "package main\n") + mustWriteFile(t, filepath.Join(root, "schema.ts"), "export const schema = 1\n") + mustWriteFile(t, filepath.Join(root, "generated", "hidden.go"), "package generated\n") + + cachedFileCount = -1 + t.Cleanup(func() { cachedFileCount = -1 }) + envelope := buildContextEnvelope(root, "", true) + + if envelope.Project.FileCount != 1 { + t.Fatalf("file count = %d, want 1", envelope.Project.FileCount) + } + if !reflect.DeepEqual(envelope.Project.Languages, []string{"go"}) { + t.Fatalf("languages = %#v, want [go]", envelope.Project.Languages) + } +} + func mustWriteFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/config/config.go b/config/config.go index caa914e..48604ec 100644 --- a/config/config.go +++ b/config/config.go @@ -13,13 +13,37 @@ import ( // ProjectConfig holds per-project defaults from .codemap/config.json. // All fields are optional; zero values mean "no preference". type ProjectConfig struct { - Only []string `json:"only,omitempty"` - Exclude []string `json:"exclude,omitempty"` - Depth int `json:"depth,omitempty"` - Mode string `json:"mode,omitempty"` - Budgets HookBudgets `json:"budgets,omitempty"` - Routing RoutingConfig `json:"routing,omitempty"` - Drift DriftConfig `json:"drift,omitempty"` + Only []string `json:"only,omitempty"` + Exclude []string `json:"exclude,omitempty"` + Depth int `json:"depth,omitempty"` + Mode string `json:"mode,omitempty"` + Budgets HookBudgets `json:"budgets,omitempty"` + Routing RoutingConfig `json:"routing,omitempty"` + Drift DriftConfig `json:"drift,omitempty"` + Guidance GuidanceConfig `json:"guidance,omitempty"` +} + +// GuidanceConfig controls optional suggestions without changing project config. +type GuidanceConfig struct { + MissingExtensionHints *bool `json:"missing_extension_hints,omitempty"` + IgnoredExtensions []string `json:"ignored_extensions,omitempty"` +} + +// MissingExtensionHintsEnabled defaults missing-extension guidance to on. +func (c ProjectConfig) MissingExtensionHintsEnabled() bool { + return c.Guidance.MissingExtensionHints == nil || *c.Guidance.MissingExtensionHints +} + +// IgnoresGuidanceForExtension reports whether guidance is disabled for ext. +func (c ProjectConfig) IgnoresGuidanceForExtension(ext string) bool { + ext = strings.TrimPrefix(strings.TrimSpace(ext), ".") + for _, ignored := range c.Guidance.IgnoredExtensions { + ignored = strings.TrimPrefix(strings.TrimSpace(ignored), ".") + if strings.EqualFold(ext, ignored) { + return true + } + } + return false } // HookBudgets configures per-hook output constraints. @@ -129,6 +153,9 @@ func (c ProjectConfig) IsZero() bool { if c.Drift.Enabled || c.Drift.RecentCommits > 0 || len(c.Drift.RequireDocsFor) > 0 { return false } + if c.Guidance.MissingExtensionHints != nil || len(c.Guidance.IgnoredExtensions) > 0 { + return false + } return true } diff --git a/mcp/find_guidance.go b/mcp/find_guidance.go new file mode 100644 index 0000000..8d6b09f --- /dev/null +++ b/mcp/find_guidance.go @@ -0,0 +1,61 @@ +package codemapmcp + +import ( + "fmt" + "sort" + "strings" + + "codemap/config" + "codemap/scanner" +) + +func findConfiguredMatches(root, pattern string, files []scanner.FileInfo) (visible []string, filtered []scanner.FileInfo, hintsEnabled bool) { + cfg := config.Load(root) + pattern = strings.ToLower(pattern) + for _, file := range files { + if !strings.Contains(strings.ToLower(file.Path), pattern) { + continue + } + if scanner.MatchesFilters(file.Path, file.Ext, cfg.Only, cfg.Exclude) { + visible = append(visible, file.Path) + continue + } + if !scanner.MatchesFilters(file.Path, file.Ext, cfg.Only, nil) && + scanner.MatchesFilters(file.Path, file.Ext, nil, cfg.Exclude) && + !cfg.IgnoresGuidanceForExtension(file.Ext) { + filtered = append(filtered, file) + } + } + return visible, filtered, cfg.MissingExtensionHintsEnabled() +} + +func formatOnlyFilterHint(pattern string, matches []scanner.FileInfo) string { + const maxPaths = 5 + paths := make([]string, 0, min(len(matches), maxPaths)) + extensions := make(map[string]struct{}) + for i, match := range matches { + if i < maxPaths { + paths = append(paths, match.Path) + } + if ext := strings.TrimPrefix(strings.ToLower(match.Ext), "."); ext != "" { + extensions[ext] = struct{}{} + } + } + exts := make([]string, 0, len(extensions)) + for ext := range extensions { + exts = append(exts, ext) + } + sort.Strings(exts) + + output := fmt.Sprintf("No configured files found matching '%s'.\n\nMatches excluded by `only` config:\n%s", pattern, strings.Join(paths, "\n")) + if remaining := len(matches) - len(paths); remaining > 0 { + output += fmt.Sprintf("\n... and %d more", remaining) + } + if len(exts) > 0 { + extList := strings.Join(exts, ", ") + output += fmt.Sprintf("\n\nTell your agent: “include suggestions for %s”, “ignore suggestions for %s”, or “disable suggestions for this repo”.", extList, extList) + } else { + output += "\n\nTell your agent: “disable suggestions for this repo”." + } + return output + "\n\nNo config changed." +} diff --git a/mcp/main.go b/mcp/main.go index c99f002..3a15177 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -221,7 +221,7 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Pat } gitCache := scanner.NewGitIgnoreCache(input.Path) - files, err := scanner.ScanFiles(input.Path, gitCache, nil, nil) + files, err := scanner.ScanConfiguredFiles(input.Path, gitCache) if err != nil { return errorResult("Scan error: " + err.Error()), nil, nil } @@ -331,7 +331,7 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu } gitCache := scanner.NewGitIgnoreCache(input.Path) - files, err := scanner.ScanFiles(input.Path, gitCache, nil, nil) + files, err := scanner.ScanConfiguredFiles(input.Path, gitCache) if err != nil { return errorResult("Scan error: " + err.Error()), nil, nil } @@ -362,15 +362,12 @@ func handleFindFile(ctx context.Context, req *mcp.CallToolRequest, input FindInp } // Filter files matching pattern (case-insensitive) - var matches []string - pattern := strings.ToLower(input.Pattern) - for _, f := range files { - if strings.Contains(strings.ToLower(f.Path), pattern) { - matches = append(matches, f.Path) - } - } + matches, filteredMatches, hintsEnabled := findConfiguredMatches(input.Path, input.Pattern, files) if len(matches) == 0 { + if hintsEnabled && len(filteredMatches) > 0 { + return textResult(formatOnlyFilterHint(input.Pattern, filteredMatches)), nil, nil + } return textResult("No files found matching '" + input.Pattern + "'"), nil, nil } @@ -483,7 +480,7 @@ func handleListProjects(ctx context.Context, req *mcp.CallToolRequest, input Lis // Uses the same scanner logic as the main codemap command (respects nested .gitignore files) func getProjectStats(path string) string { gitCache := scanner.NewGitIgnoreCache(path) - files, err := scanner.ScanFiles(path, gitCache, nil, nil) + files, err := scanner.ScanConfiguredFiles(path, gitCache) if err != nil { return "(error scanning)" } diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go index 7def6ea..78881ee 100644 --- a/mcp/main_more_test.go +++ b/mcp/main_more_test.go @@ -109,6 +109,95 @@ func TestHandleGetDependenciesAndDiff(t *testing.T) { } } +func TestMCPScansRespectConfiguredFilters(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := makeMCPGitRepo(t, "main") + files := map[string]string{ + ".codemap/config.json": `{"only":["go"],"exclude":["excluded"]}`, + "go.mod": "module example.com/mcpfiltered\n\ngo 1.22\n", + "pkg/types/types.go": "package types\n\ntype Item struct{}\n", + "a/a.go": "package a\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n", + "b/b.go": "package b\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n", + "c/c.go": "package c\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n", + "schema.ts": "export const schema = 1\n", + "excluded/d/d.go": "package d\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + runGitMCPTestCmd(t, root, "add", ".") + runGitMCPTestCmd(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "fixture") + + for path, content := range map[string]string{ + "a/a.go": "package a\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n\nfunc Changed() {}\n", + "schema.ts": "export const schema = 2\n", + "excluded/d/d.go": "package d\n\nimport _ \"example.com/mcpfiltered/pkg/types\"\n\nfunc Changed() {}\n", + } { + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + structure, _, err := handleGetStructure(context.Background(), nil, PathInput{Path: root}) + if err != nil { + t.Fatalf("handleGetStructure error: %v", err) + } + structureOut := resultText(t, structure) + for _, want := range []string{"main.go", "types.go", "a.go"} { + if !strings.Contains(structureOut, want) { + t.Fatalf("structure missing %q:\n%s", want, structureOut) + } + } + for _, unwanted := range []string{"schema.ts", "excluded"} { + if strings.Contains(structureOut, unwanted) { + t.Fatalf("structure contains configured-out %q:\n%s", unwanted, structureOut) + } + } + + diff, _, err := handleGetDiff(context.Background(), nil, DiffInput{Path: root, Ref: "main"}) + if err != nil { + t.Fatalf("handleGetDiff error: %v", err) + } + diffOut := resultText(t, diff) + if !strings.Contains(diffOut, "a.go") || strings.Contains(diffOut, "schema.ts") || strings.Contains(diffOut, "excluded") { + t.Fatalf("unexpected filtered diff:\n%s", diffOut) + } + + deps, _, err := handleGetDependencies(context.Background(), nil, PathInput{Path: root}) + if err != nil { + t.Fatalf("handleGetDependencies error: %v", err) + } + depsOut := resultText(t, deps) + if strings.Contains(depsOut, "schema.ts") || strings.Contains(depsOut, "excluded/d/d.go") { + t.Fatalf("dependencies contain configured-out files:\n%s", depsOut) + } + + hubs, _, err := handleGetHubs(context.Background(), nil, PathInput{Path: root}) + if err != nil { + t.Fatalf("handleGetHubs error: %v", err) + } + hubsOut := resultText(t, hubs) + if !strings.Contains(hubsOut, "pkg/types/types.go (3 importers)") || strings.Contains(hubsOut, "excluded/d/d.go") { + t.Fatalf("unexpected filtered hubs:\n%s", hubsOut) + } + + if got := getProjectStats(root); !strings.Contains(got, "(5 files, Go") { + t.Fatalf("project stats = %q, want 5 filtered Go files", got) + } +} + func TestHandleWatchLifecycleAndActivity(t *testing.T) { withWatcherRegistry(t) diff --git a/mcp/main_test.go b/mcp/main_test.go index bb8b847..616ea41 100644 --- a/mcp/main_test.go +++ b/mcp/main_test.go @@ -10,6 +10,7 @@ import ( "time" "codemap/handoff" + "codemap/scanner" "codemap/watch" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -120,6 +121,89 @@ func TestHandleFindFileAndStatus(t *testing.T) { } } +func TestHandleFindFileExplainsOnlyFilteredMatches(t *testing.T) { + tests := []struct { + name string + config string + wantHint bool + wantPath bool + }{ + { + name: "default guidance", + config: `{"only":["go"]}`, + wantHint: true, + wantPath: true, + }, + { + name: "guidance disabled", + config: `{"only":["go"],"guidance":{"missing_extension_hints":false}}`, + }, + { + name: "extension ignored", + config: `{"only":["go"],"guidance":{"ignored_extensions":["proto"]}}`, + }, + { + name: "explicitly excluded", + config: `{"only":["go"],"exclude":["schema.proto"]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(root, ".codemap", "config.json") + if err := os.WriteFile(configPath, []byte(tt.config), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "schema.proto"), []byte("syntax = \"proto3\";\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, _, err := handleFindFile(context.Background(), nil, FindInput{Path: root, Pattern: "schema"}) + if err != nil { + t.Fatalf("handleFindFile error: %v", err) + } + out := resultText(t, res) + hasHint := strings.Contains(out, "by `only` config:") + if hasHint != tt.wantHint { + t.Fatalf("hint presence = %v, want %v:\n%s", hasHint, tt.wantHint, out) + } + if strings.Contains(out, "schema.proto") != tt.wantPath { + t.Fatalf("path presence = %v, want %v:\n%s", strings.Contains(out, "schema.proto"), tt.wantPath, out) + } + if !tt.wantHint && out != "No files found matching 'schema'" { + t.Fatalf("unexpected plain miss output: %s", out) + } + gotConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(gotConfig) != tt.config { + t.Fatalf("config changed: got %q, want %q", gotConfig, tt.config) + } + }) + } +} + +func TestFormatOnlyFilterHintOffersSortedAgentChoices(t *testing.T) { + matches := []scanner.FileInfo{ + {Path: "schema.sum", Ext: ".sum"}, + {Path: "schema.proto", Ext: ".proto"}, + } + + out := formatOnlyFilterHint("schema", matches) + want := "Tell your agent: “include suggestions for proto, sum”, “ignore suggestions for proto, sum”, or “disable suggestions for this repo”." + if !strings.Contains(out, want) { + t.Fatalf("missing concise agent choices:\n%s", out) + } + if strings.Contains(out, ".codemap/config.json") || strings.Contains(out, "guidance.") { + t.Fatalf("response exposes config implementation details:\n%s", out) + } +} + func TestHandleGetStructureUsesStateHubs(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { diff --git a/mcp_fallback_entrypoints_test.go b/mcp_fallback_entrypoints_test.go new file mode 100644 index 0000000..0aa6080 --- /dev/null +++ b/mcp_fallback_entrypoints_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestMCPEntrypointsMatchConfigFallback(t *testing.T) { + binDir := t.TempDir() + cli := buildFallbackTestBinary(t, filepath.Join(binDir, "codemap"), ".") + standalone := buildFallbackTestBinary(t, filepath.Join(binDir, "codemap-mcp"), "./cmd/codemap-mcp") + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "schema.proto"), []byte("syntax = \"proto3\";\n"), 0o644); err != nil { + t.Fatal(err) + } + + cliResult := probeFallbackTestBinary(t, cli, []string{"mcp"}, root) + standaloneResult := probeFallbackTestBinary(t, standalone, nil, root) + if cliResult != standaloneResult { + t.Fatalf("find_file differs:\ncodemap mcp: %s\ncodemap-mcp: %s", cliResult, standaloneResult) + } + if !strings.Contains(cliResult, "Matches excluded by `only` config:") { + t.Fatalf("fallback guidance missing: %s", cliResult) + } +} + +func buildFallbackTestBinary(t *testing.T, output, pkg string) string { + t.Helper() + command := exec.Command("go", "build", "-o", output, pkg) + if data, err := command.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", pkg, err, data) + } + return output +} + +func probeFallbackTestBinary(t *testing.T, binary string, args []string, root string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := mcp.NewClient(&mcp.Implementation{Name: "fallback-parity-test", Version: "1"}, nil) + session, err := client.Connect(ctx, &mcp.CommandTransport{Command: exec.Command(binary, args...)}, nil) + if err != nil { + t.Fatalf("connect %s: %v", binary, err) + } + defer session.Close() + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "find_file", + Arguments: map[string]any{"path": root, "pattern": "schema"}, + }) + if err != nil { + t.Fatalf("find_file from %s: %v", binary, err) + } + var text []string + for _, content := range result.Content { + if item, ok := content.(*mcp.TextContent); ok { + text = append(text, item.Text) + } + } + return strings.Join(text, "\n") +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 38bb0a1..4625297 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -47,6 +47,8 @@ func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGrap return nil, err } + analyses = filterConfiguredAnalyses(root, analyses) + fg := &FileGraph{ Root: absRoot, Imports: make(map[string][]string), @@ -63,7 +65,7 @@ func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGrap // Scan all files gitCache := NewGitIgnoreCache(root) - files, err := ScanFiles(root, gitCache, nil, nil) + files, err := ScanConfiguredFiles(root, gitCache) if err != nil { return nil, err } diff --git a/scanner/integration_more_test.go b/scanner/integration_more_test.go index 0b36e59..0b44efb 100644 --- a/scanner/integration_more_test.go +++ b/scanner/integration_more_test.go @@ -3,6 +3,7 @@ package scanner import ( "os" "path/filepath" + "strings" "testing" ) @@ -80,3 +81,54 @@ func TestScanForDepsBuildFileGraphAndAnalyzeImpact(t *testing.T) { t.Fatalf("expected impacted file usage count >= 4, got %+v", impacts) } } + +func TestDependencyAndGraphScansRespectConfiguredFilters(t *testing.T) { + if !NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + files := map[string]string{ + ".codemap/config.json": `{"only":["go"],"exclude":["excluded"]}`, + "go.mod": "module example.com/filtered\n\ngo 1.22\n", + "pkg/types/types.go": "package types\n\ntype Item struct{}\n", + "a/a.go": "package a\n\nimport _ \"example.com/filtered/pkg/types\"\n", + "b/b.go": "package b\n\nimport _ \"example.com/filtered/pkg/types\"\n", + "c/c.go": "package c\n\nimport _ \"example.com/filtered/pkg/types\"\n", + "schema.ts": "import './blocked'\n", + "excluded/d/d.go": "package d\n\nimport _ \"example.com/filtered/pkg/types\"\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + analyses, err := ScanForDeps(root) + if err != nil { + t.Fatalf("ScanForDeps() error: %v", err) + } + for _, analysis := range analyses { + if analysis.Path == "schema.ts" || strings.HasPrefix(analysis.Path, "excluded/") { + t.Fatalf("configured-out dependency analysis returned: %s", analysis.Path) + } + } + + fg, err := BuildFileGraph(root) + if err != nil { + t.Fatalf("BuildFileGraph() error: %v", err) + } + if got := fg.Importers["pkg/types/types.go"]; len(got) != 3 { + t.Fatalf("filtered hub importers = %#v, want exactly 3", got) + } + if _, ok := fg.Imports["excluded/d/d.go"]; ok { + t.Fatal("excluded Go file remains in graph") + } + if _, ok := fg.Imports["schema.ts"]; ok { + t.Fatal("extension-filtered file remains in graph") + } +} diff --git a/scanner/walker.go b/scanner/walker.go index 48faca6..fdb84b2 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" + "codemap/config" + ignore "github.com/sabhiram/go-gitignore" ) @@ -201,6 +203,11 @@ func shouldIncludeFile(relPath string, ext string, only []string, exclude []stri return true } +// MatchesFilters reports whether a file passes project only/exclude filters. +func MatchesFilters(relPath string, ext string, only []string, exclude []string) bool { + return shouldIncludeFile(relPath, ext, only, exclude) +} + // LoadGitignore loads .gitignore from root if it exists // Deprecated: Use NewGitIgnoreCache for nested gitignore support func LoadGitignore(root string) *ignore.GitIgnore { @@ -287,17 +294,43 @@ func ScanFiles(root string, cache *GitIgnoreCache, only []string, exclude []stri return files, err } +// ScanConfiguredFiles scans using the active setup root's project filters. +func ScanConfiguredFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) { + cfg := config.Load(root) + return ScanFiles(root, cache, cfg.Only, cfg.Exclude) +} + +func filterConfiguredAnalyses(root string, analyses []FileAnalysis) []FileAnalysis { + cfg := config.Load(root) + if len(cfg.Only) == 0 && len(cfg.Exclude) == 0 { + return analyses + } + + filtered := make([]FileAnalysis, 0, len(analyses)) + for _, analysis := range analyses { + path := filepath.ToSlash(analysis.Path) + if MatchesFilters(path, filepath.Ext(path), cfg.Only, cfg.Exclude) { + filtered = append(filtered, analysis) + } + } + return filtered +} + // ScanForDeps uses ast-grep for batched dependency analysis. func ScanForDeps(root string) ([]FileAnalysis, error) { - scanner, err := NewAstGrepScanner() + astScanner, err := NewAstGrepScanner() if err != nil { return nil, err } - defer scanner.Close() + defer astScanner.Close() - if !scanner.Available() { + if !astScanner.Available() { return nil, ErrAstGrepNotFound } - return scanner.ScanDirectory(root) + analyses, err := astScanner.ScanDirectory(root) + if err != nil { + return nil, err + } + return filterConfiguredAnalyses(root, analyses), nil }