Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
}
Expand Down
30 changes: 30 additions & 0 deletions cmd/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"os"
"path/filepath"
"reflect"
"testing"
)

Expand Down Expand Up @@ -41,6 +42,35 @@ 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 TestCountSourceFilesReturnsZeroWhenConfiguredScanFails(t *testing.T) {
cachedFileCount = -1
t.Cleanup(func() { cachedFileCount = -1 })

missingRoot := filepath.Join(t.TempDir(), "missing")
if got := countSourceFiles(missingRoot); got != 0 {
t.Fatalf("countSourceFiles(missing root) = %d, want 0", got)
}
}

func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand Down
41 changes: 34 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
32 changes: 32 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,38 @@ func TestProjectConfigStateHelpers(t *testing.T) {
})
}

func TestGuidanceConfigHelpers(t *testing.T) {
var zero ProjectConfig
if !zero.MissingExtensionHintsEnabled() {
t.Fatal("expected missing-extension hints to default to enabled")
}

enabled, disabled := true, false
if !(ProjectConfig{Guidance: GuidanceConfig{MissingExtensionHints: &enabled}}).MissingExtensionHintsEnabled() {
t.Fatal("expected explicitly enabled missing-extension hints")
}
if (ProjectConfig{Guidance: GuidanceConfig{MissingExtensionHints: &disabled}}).MissingExtensionHintsEnabled() {
t.Fatal("expected explicitly disabled missing-extension hints")
}

cfg := ProjectConfig{Guidance: GuidanceConfig{IgnoredExtensions: []string{" .Proto ", "SUM"}}}
for _, ext := range []string{".proto", " PROTO", ".sum"} {
if !cfg.IgnoresGuidanceForExtension(ext) {
t.Fatalf("expected extension %q to be ignored", ext)
}
}
if cfg.IgnoresGuidanceForExtension(".rs") {
t.Fatal("did not expect rs guidance to be ignored")
}

if (ProjectConfig{Guidance: GuidanceConfig{MissingExtensionHints: &enabled}}).IsZero() {
t.Fatal("config with missing-extension policy must not be zero")
}
if (ProjectConfig{Guidance: GuidanceConfig{IgnoredExtensions: []string{"proto"}}}).IsZero() {
t.Fatal("config with ignored guidance extensions must not be zero")
}
}

func TestAssessSetup(t *testing.T) {
t.Run("missing config needs setup", func(t *testing.T) {
assessment := AssessSetup(t.TempDir())
Expand Down
61 changes: 61 additions & 0 deletions mcp/find_guidance.go
Original file line number Diff line number Diff line change
@@ -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."
}
17 changes: 7 additions & 10 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)"
}
Expand Down
89 changes: 89 additions & 0 deletions mcp/main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading