Skip to content
Open
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
20 changes: 20 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,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 {
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
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