From 27a6edaac081c89730645b5918c5b10d00020d41 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 11 Sep 2026 14:32:41 -0400 Subject: [PATCH 1/5] feat: codemap find, rank files by path and symbol match with importer counts Lexical BM25 over what the scanner already extracts (paths and function names, split on camelCase, snake_case and separators), plus a whole-word bonus. Each hit carries importer count and hub status from the same file graph the other commands use, and ends with the coverage line. Exposed as the `find` MCP tool as well. No new dependencies, no index on disk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- README.md | 5 +- find/find.go | 256 ++++++++++++++++++++++++++++++++++++ find/find_test.go | 48 +++++++ find_cmd.go | 79 +++++++++++ main.go | 10 ++ mcp/main.go | 37 ++++++ mcp/surface_hygiene_test.go | 2 + 7 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 find/find.go create mode 100644 find/find_test.go create mode 100644 find_cmd.go diff --git a/README.md b/README.md index f887d0f..c9e0155 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ codemap --deps . # dependency flow codemap --importers f # who imports a file codemap blast-radius # review bundle: diff + deps + importers codemap collide # rank open PRs by shared-file merge-order hazard +codemap find "" # rank files by path and symbol match, with importer counts codemap handoff . # save layered handoff for cross-agent continuation codemap context # machine-readable project context JSON codemap doctor # validate agent integrations @@ -310,11 +311,11 @@ The prompt-submit hook classifies intent, surfaces hub-file risk, shows your wor ### MCP -`codemap mcp` serves 16 tools over stdio: +`codemap mcp` serves 19 tools over stdio: | Category | Tools | |----------|-------| -| Structure | `get_structure`, `find_file`, `get_hubs`, `get_file_context` | +| Structure | `get_structure`, `find`, `find_file`, `get_hubs`, `get_file_context` | | Dependencies | `get_dependencies`, `get_importers`, `get_diff` | | Session | `get_working_set`, `get_activity`, `get_handoff` | | Daemon | `start_watch`, `stop_watch`, `status` | diff --git a/find/find.go b/find/find.go new file mode 100644 index 0000000..61b02d5 --- /dev/null +++ b/find/find.go @@ -0,0 +1,256 @@ +// Package find ranks project files against a natural-language query using +// only what the dependency scanner already extracts: file paths and the +// function names inside them. It is lexical (BM25 over split identifiers), +// not semantic; a query has to share vocabulary with the code it wants. +package find + +import ( + "context" + "fmt" + "io" + "math" + "sort" + "strings" + "unicode" + + "codemap/analysis" + "codemap/scanner" +) + +const ( + Schema = "codemap.find/v1" + DefaultLimit = 10 + bm25K1 = 1.2 + bm25B = 0.75 + maxMatched = 5 + // exactBonus is added once per query word that is a whole word of a + // symbol or the file basename, so "theme" pulls loadTheme ahead of a file + // whose path merely shares a directory token. + exactBonus = 1.0 +) + +type Hit struct { + Path string `json:"path"` + Score float64 `json:"score"` + Matched []string `json:"matched"` + Importers int `json:"importers"` + Hub bool `json:"hub"` +} + +type Report struct { + Schema string `json:"schema"` + Query string `json:"query"` + Hits []Hit `json:"hits"` + Coverage analysis.Coverage `json:"coverage"` +} + +// Run scans root, ranks files against query, and annotates hits with importer +// counts from the same graph every other codemap command uses. +func Run(ctx context.Context, root string, filters scanner.Filters, query string, limit int) (Report, error) { + outcome, err := scanner.ScanForDeps(ctx, root, filters) + if err != nil { + return Report{}, err + } + report := Report{Schema: Schema, Query: query, Hits: Rank(outcome.Analyses, query, limit)} + sources := outcome.Sources + if fg, graphErr := scanner.BuildFileGraphFromOutcome(ctx, root, outcome, filters); graphErr == nil { + sources = fg.Coverage.Sources + for i := range report.Hits { + importers := fg.Importers[report.Hits[i].Path] + report.Hits[i].Importers = len(importers) + report.Hits[i].Hub = scanner.CountHubImporters(importers) >= scanner.HubThreshold + } + } + report.Coverage = scanner.CoverageFromSources(sources) + return report, nil +} + +// Rank scores every analysis against query and returns the top limit hits +// with a positive score. Importer fields are left zero; Run fills them. +func Rank(analyses []scanner.FileAnalysis, query string, limit int) []Hit { + if limit <= 0 { + limit = DefaultLimit + } + queryTokens := tokenize(query) + if len(queryTokens) == 0 { + return nil + } + rawQuery := rawTokens(query) + + docs := make([][]string, len(analyses)) + df := make(map[string]int) + totalLen := 0 + for i, a := range analyses { + docs[i] = tokenize(strings.Join(append([]string{a.Path}, a.Functions...), " ")) + totalLen += len(docs[i]) + for _, tok := range unique(docs[i]) { + df[tok]++ + } + } + n := float64(len(analyses)) + avgLen := 1.0 + if n > 0 { + avgLen = math.Max(1, float64(totalLen)/n) + } + + hits := make([]Hit, 0, len(analyses)) + for i, a := range analyses { + tf := make(map[string]int, len(docs[i])) + for _, tok := range docs[i] { + tf[tok]++ + } + score := 0.0 + for _, q := range queryTokens { + f := float64(tf[q]) + if f == 0 { + continue + } + idf := math.Log((n-float64(df[q])+0.5)/(float64(df[q])+0.5) + 1) + score += idf * (f * (bm25K1 + 1)) / (f + bm25K1*(1-bm25B+bm25B*float64(len(docs[i]))/avgLen)) + } + matched := matchedNames(a, rawQuery) + score += exactBonus * float64(countExact(a, rawQuery)) + if score <= 0 { + continue + } + hits = append(hits, Hit{Path: a.Path, Score: round(score), Matched: matched}) + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].Score != hits[j].Score { + return hits[i].Score > hits[j].Score + } + return hits[i].Path < hits[j].Path + }) + if len(hits) > limit { + hits = hits[:limit] + } + return hits +} + +// Render writes the compact text form: one block per hit, then coverage. +func Render(w io.Writer, r Report) { + if len(r.Hits) == 0 { + fmt.Fprintf(w, "No files match %q\n", r.Query) + } + for _, h := range r.Hits { + fmt.Fprintln(w, h.Path) + if len(h.Matched) > 0 { + fmt.Fprintf(w, " matched: %s\n", strings.Join(h.Matched, ", ")) + } + hub := "" + if h.Hub { + hub = " (hub)" + } + fmt.Fprintf(w, " importers: %d%s\n", h.Importers, hub) + } + notes := make([]string, 0, len(r.Coverage.Sources)) + for _, s := range r.Coverage.Sources { + if s.Detail != "" { + notes = append(notes, s.Detail) + } + } + if len(notes) == 0 { + fmt.Fprintf(w, "Coverage: %s\n", r.Coverage.Status) + return + } + fmt.Fprintf(w, "Coverage: %s — %s\n", r.Coverage.Status, strings.Join(notes, "; ")) +} + +// tokenize splits identifiers and paths into lowercase words: camelCase, +// snake_case, kebab-case, dots and slashes are all boundaries. +func tokenize(s string) []string { + var out []string + var cur []rune + flush := func() { + if len(cur) >= 2 { + out = append(out, strings.ToLower(string(cur))) + } + cur = cur[:0] + } + runes := []rune(s) + for i, r := range runes { + switch { + case !unicode.IsLetter(r) && !unicode.IsDigit(r): + flush() + case unicode.IsUpper(r) && len(cur) > 0 && (!unicode.IsUpper(runes[i-1]) || + // HTTPServer: split before the last upper of an acronym run. + i+1 < len(runes) && unicode.IsLower(runes[i+1])): + flush() + cur = append(cur, r) + default: + cur = append(cur, r) + } + } + flush() + return out +} + +// rawTokens keeps whole query words (lowercased) for the exact-substring bonus. +func rawTokens(s string) []string { + return unique(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + })) +} + +func names(a scanner.FileAnalysis) []string { + base := a.Path + if i := strings.LastIndexAny(base, `/\`); i >= 0 { + base = base[i+1:] + } + return append([]string{base}, a.Functions...) +} + +// hasWord reports whether q is one of name's split words (loadTheme has +// "theme"; DataModel does not have "mode"). +func hasWord(name, q string) bool { + for _, w := range tokenize(name) { + if w == q { + return true + } + } + return false +} + +func matchedNames(a scanner.FileAnalysis, raw []string) []string { + var out []string + for _, name := range names(a) { + for _, q := range raw { + if hasWord(name, q) { + out = append(out, name) + break + } + } + if len(out) == maxMatched { + break + } + } + return out +} + +func countExact(a scanner.FileAnalysis, raw []string) int { + count := 0 + for _, q := range raw { + for _, name := range names(a) { + if hasWord(name, q) { + count++ + break + } + } + } + return count +} + +func unique(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := in[:0:0] + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func round(f float64) float64 { return math.Round(f*1000) / 1000 } diff --git a/find/find_test.go b/find/find_test.go new file mode 100644 index 0000000..568979a --- /dev/null +++ b/find/find_test.go @@ -0,0 +1,48 @@ +package find + +import ( + "bytes" + "strings" + "testing" + + "codemap/analysis" + "codemap/scanner" +) + +func TestRankPrefersSymbolVocabulary(t *testing.T) { + analyses := []scanner.FileAnalysis{ + {Path: "src/theme/use-theme.ts", Functions: []string{"useTheme", "saveTheme"}}, + {Path: "src/storage/preferences.ts", Functions: []string{"loadTheme", "persistPreference"}}, + {Path: "src/net/client.ts", Functions: []string{"fetchJSON", "retry"}}, + } + hits := Rank(analyses, "theme persistence", 10) + if len(hits) != 2 { + t.Fatalf("want 2 hits, got %+v", hits) + } + // Lexical, no stemming: "persistence" does not match persistPreference, + // so the file that says "theme" three times ranks first. + if hits[0].Path != "src/theme/use-theme.ts" || hits[1].Path != "src/storage/preferences.ts" { + t.Fatalf("theme files should rank, unrelated dropped, got %+v", hits) + } + if got := strings.Join(hits[0].Matched, ","); got != "use-theme.ts,useTheme,saveTheme" { + t.Fatalf("matched = %q", got) + } + if got := tokenize("HTTPServer_loadTheme.v2"); strings.Join(got, " ") != "http server load theme v2" { + t.Fatalf("tokenize = %v", got) + } +} + +func TestRenderShape(t *testing.T) { + var buf bytes.Buffer + Render(&buf, Report{ + Query: "theme", + Hits: []Hit{{Path: "a/b.swift", Matched: []string{"loadTheme"}, Importers: 3, Hub: true}}, + Coverage: analysis.Coverage{Status: analysis.CoveragePartial, Sources: []analysis.Source{ + {Name: "symbol-imports/swift", Status: analysis.SourceUnavailable, Detail: "Swift edges missing"}, + }}, + }) + want := "a/b.swift\n matched: loadTheme\n importers: 3 (hub)\nCoverage: partial — Swift edges missing\n" + if buf.String() != want { + t.Fatalf("render =\n%s\nwant\n%s", buf.String(), want) + } +} diff --git a/find_cmd.go b/find_cmd.go new file mode 100644 index 0000000..57d423e --- /dev/null +++ b/find_cmd.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "codemap/cmd" + "codemap/config" + "codemap/find" + "codemap/scanner" +) + +func runFindSubcommand(args []string, launchDir string) int { + fs := flag.NewFlagSet("find", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + limit := fs.Int("limit", find.DefaultLimit, "Maximum hits to return") + jsonMode := fs.Bool("json", false, "Emit a single JSON object") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "Usage: codemap find [options] ") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Rank files by how well their path and function names match the query,") + fmt.Fprintln(os.Stderr, "annotated with importer counts so a hit also says how risky it is to edit.") + fmt.Fprintln(os.Stderr, "Lexical only: the query must share words with the code it is looking for.") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Options:") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + query := strings.TrimSpace(strings.Join(fs.Args(), " ")) + if query == "" { + fmt.Fprintln(os.Stderr, "Error: codemap find needs a query") + return 2 + } + if *limit <= 0 { + fmt.Fprintln(os.Stderr, "Error: --limit must be greater than zero") + return 2 + } + + absRoot, err := filepath.Abs(launchDir) + if err == nil { + absRoot, _, err = cmd.ResolveNearestGitRoot(absRoot) + } + if err == nil { + _, err = cmd.ValidateProjectPath(absRoot) + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving project root: %v\n", err) + return 1 + } + + cfg := config.Load(absRoot) + report, err := find.Run(context.Background(), absRoot, scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}, query, *limit) + if err != nil { + fmt.Fprintf(os.Stderr, "Error scanning project: %v\n", err) + return 1 + } + if *jsonMode { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) + return 1 + } + return 0 + } + find.Render(os.Stdout, report) + return 0 +} diff --git a/main.go b/main.go index f784c5c..924d6f6 100644 --- a/main.go +++ b/main.go @@ -211,6 +211,15 @@ func main() { return } + // Handle "find" subcommand before global flag parsing + if len(os.Args) >= 2 && os.Args[1] == "find" { + root, _ := os.Getwd() + if code := runFindSubcommand(os.Args[2:], root); code != 0 { + os.Exit(code) + } + return + } + // Handle "collide" subcommand before global flag parsing if len(os.Args) >= 2 && os.Args[1] == "collide" { root, _ := os.Getwd() @@ -303,6 +312,7 @@ func main() { fmt.Println(" codemap handoff [path] # Build handoff artifact for agent switching") fmt.Println(" codemap blast-radius [path] # Compact bounded blast-radius bundle") fmt.Println(" codemap collide # Rank open PRs by shared-file merge-order hazard") + fmt.Println(" codemap find # Rank files by path and symbol match, with importer counts") fmt.Println() fmt.Println("Project config:") fmt.Println(" codemap config init # Create .codemap/config.json (auto-detects extensions)") diff --git a/mcp/main.go b/mcp/main.go index cff97ca..ea7f301 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -19,6 +19,7 @@ import ( "unicode/utf8" "codemap/config" + "codemap/find" "codemap/handoff" "codemap/internal/buildinfo" "codemap/internal/projectpath" @@ -76,6 +77,7 @@ var statusTools = []statusTool{ {"get_diff", "Changed files vs branch"}, {"find_file", "Search by filename"}, {"get_importers", "Find what imports a file"}, + {"find", "Rank files by path and symbol match"}, {"status", "Verify MCP connection"}, {"start_watch", "Start watching a project"}, {"stop_watch", "Stop watching a project"}, @@ -169,6 +171,12 @@ type FindInput struct { Pattern string `json:"pattern" jsonschema:"Filename pattern to search for (case-insensitive substring match)"` } +type FindQueryInput struct { + Path string `json:"path" jsonschema:"Path to the project directory to search"` + Query string `json:"query" jsonschema:"Natural-language or identifier query, e.g. 'theme persistence' or 'loadTheme'"` + Limit int `json:"limit,omitempty" jsonschema:"Maximum hits to return (default 10)"` +} + type ImportersInput struct { Path string `json:"path" jsonschema:"Path to the project directory"` File string `json:"file" jsonschema:"Relative path to the file to check (e.g. src/utils.ts)"` @@ -248,6 +256,12 @@ func NewServer(options RuntimeOptions) *mcp.Server { OutputSchema: mustSchemaFor[ImportersOutput](), }, handleGetImporters) + // Tool: find - Rank files against a query using paths and function names + mcp.AddTool(server, &mcp.Tool{ + Name: "find", + Description: "Rank project files by how well their path and function names match a query (lexical BM25 over split identifiers). Each hit reports importer count and hub status, so it says where the code is and how risky it is to edit. Use before grep when you know what the code does but not where it lives.", + }, handleFind) + // Tool: status - Verify MCP connection mcp.AddTool(server, &mcp.Tool{ Name: "status", @@ -907,6 +921,29 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp return textResult(fmt.Sprintf("%d files import '%s':%s\n%s%s", len(importers), file, hubNote, strings.Join(importers, "\n"), mcpCoverageText(fg))), structured, nil } +func handleFind(ctx context.Context, req *mcp.CallToolRequest, input FindQueryInput) (*mcp.CallToolResult, any, error) { + if cancelled := cancellationResult(ctx, "Find"); cancelled != nil { + return cancelled, nil, nil + } + absRoot, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil + } + if strings.TrimSpace(input.Query) == "" { + return errorResult("find needs a query"), nil, nil + } + report, err := find.Run(ctx, absRoot, scanner.ConfiguredFilters(absRoot), input.Query, input.Limit) + if err != nil { + if cancelled := cancellationResult(ctx, "Find"); cancelled != nil { + return cancelled, nil, nil + } + return errorResult("Failed to scan project: " + err.Error()), nil, nil + } + var buf strings.Builder + find.Render(&buf, report) + return textResult(buf.String()), nil, nil +} + func normalizeImporterFile(root, file string) string { file = filepath.FromSlash(file) if filepath.IsAbs(file) { diff --git a/mcp/surface_hygiene_test.go b/mcp/surface_hygiene_test.go index ab07bec..2f7e08d 100644 --- a/mcp/surface_hygiene_test.go +++ b/mcp/surface_hygiene_test.go @@ -67,6 +67,7 @@ func TestTextResultCallerClassification(t *testing.T) { "handleGetStructure": {1, 0}, "handleGetDependencies": {1, 0}, "handleGetDiff": {2, 0}, + "handleFind": {1, 0}, "handleFindFile": {3, 0}, "handleStatus": {1, 0}, "handleListProjects": {4, 0}, @@ -190,6 +191,7 @@ func TestStatusInventoryExactlyMatchesRegisteredTools(t *testing.T) { sort.Strings(registered) expected := []string{ + "find", "find_file", "get_activity", "get_dependencies", From 2b9ea91a7b27c46dc562cf3bd03d4b9b9dca5fc6 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 11 Sep 2026 14:34:06 -0400 Subject: [PATCH 2/5] feat(scanner): resolve Swift intra-project edges by type-name reference Swift files never import each other, so the graph over a Swift app was structurally empty and every Swift file read as having zero dependents. Extract declared class/struct/enum/actor/protocol names per file, then add an edge from any Swift file that mentions a type another file declares. Import lines are stripped first so a project enum named like a module does not gain the whole app as importers, and the declaration keyword must head the match so "class func" is never read as a type. The symbol-imports/swift source now reports mixed with a note naming the resolver and its blind spots; coverage stays partial. Kotlin, Java, C# and Scala are unchanged. On an 86-file Swift app the graph goes from 16 module-level edges to 230 file edges. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- scanner/astgrep.go | 25 +++++++++++ scanner/filegraph.go | 82 ++++++++++++++++++++++++++++++++++--- scanner/importmodel.go | 35 ++++++++++------ scanner/importmodel_test.go | 53 ++++++++++++++++++++---- scanner/sg-rules/swift.yml | 7 ++++ scanner/types.go | 3 ++ 6 files changed, 179 insertions(+), 26 deletions(-) diff --git a/scanner/astgrep.go b/scanner/astgrep.go index e255f87..bf2b057 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "sync" @@ -513,6 +514,10 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F if mod != "" { fileMap[relPath].Imports = append(fileMap[relPath].Imports, mod) } + } else if strings.HasSuffix(m.RuleID, "-types") { + if name := extractTypeName(m.Text); name != "" { + fileMap[relPath].Types = append(fileMap[relPath].Types, name) + } } else if strings.HasSuffix(m.RuleID, "-functions") { // Extract function name from text name := extractFunctionName(m.Text, fileMap[relPath].Language) @@ -526,6 +531,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F var results []FileAnalysis for _, a := range fileMap { a.Functions = dedupe(a.Functions) + a.Types = dedupe(a.Types) a.Imports = dedupe(a.Imports) a.References = dedupeImportReferences(a.References) results = append(results, *a) @@ -697,6 +703,25 @@ func extractImportPath(text string) string { return "" } +// typeDeclarationName picks the declared name out of a Swift type declaration. +// tree-sitter-swift files class, struct, enum, actor and extension under one +// node kind; an extension declares nothing new, so it yields no name. The +// keyword must be the first token after modifiers so that a nested "class +// func" or "class var" inside the body is never read as a declaration. +var typeDeclarationName = regexp.MustCompile(`^(?:(?:public|private|internal|fileprivate|open|final|indirect|@[A-Za-z_]+(?:\([^)]*\))?)\s+)*(class|struct|enum|actor|protocol)\s+([A-Za-z_][A-Za-z0-9_]*)`) + +func extractTypeName(text string) string { + m := typeDeclarationName.FindStringSubmatch(strings.TrimSpace(text)) + if m == nil { + return "" + } + switch m[2] { + case "func", "var", "let", "init": + return "" + } + return m[2] +} + func extractFunctionName(text string, lang string) string { text = strings.TrimSpace(text) diff --git a/scanner/filegraph.go b/scanner/filegraph.go index accdcd7..7e60f4c 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -7,6 +7,8 @@ import ( "os" pathpkg "path" "path/filepath" + "regexp" + "slices" "sort" "strings" @@ -189,12 +191,6 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, fg.Coverage.AddSource(ScanSourceOutcome{Name: "rust-cargo", Status: ScanSourceMixed, Detail: rustCoverageNote}) } } - // Languages whose imports name modules rather than files cannot produce - // intra-project edges at all, so an empty graph over them is a blind spot - // rather than a finding. Recording it here is what keeps --importers and - // blast-radius honest too: both read this graph's provenance. - fg.Coverage.addSymbolLevelImportCoverage(languages.symbolLevel) - var jsResolver *jsWorkspaceResolver if useJSWorkspace { jsResolver, err = buildJSWorkspaceResolver(ctx, absRoot, allFiles) @@ -254,10 +250,84 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, if err := ctx.Err(); err != nil { return nil, err } + // Languages whose imports name modules rather than files cannot produce + // intra-project edges from imports, so an empty graph over them is a blind + // spot rather than a finding. Swift gets type-name reference edges instead + // and is reported mixed; the rest stay unavailable. Recording it here is + // what keeps --importers and blast-radius honest too: both read this + // graph's provenance. + resolved := map[string]bool{} + if addSwiftTypeReferenceEdges(absRoot, analyses, fg) { + resolved[symbolLevelImportLanguages["swift"]] = true + } + fg.Coverage.addSymbolLevelImportCoverage(languages.symbolLevel, resolved) fg.sortEdges() return fg, nil } +// swiftIdentifier matches one identifier token; type references in Swift are +// bare names, so a word-boundary scan over the source is the resolver. +var swiftIdentifier = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`) + +// swiftImportLine strips import statements before the scan: "import Firebase" +// names a module, and a project enum that happens to share the name would +// otherwise gain every file in the app as an importer. +var swiftImportLine = regexp.MustCompile(`(?m)^\s*(?:@testable\s+)?import\s+[^\n]*`) + +// addSwiftTypeReferenceEdges adds file -> file edges for every Swift file that +// mentions a type declared in another Swift file. Reports whether any Swift +// type was declared at all, which is what decides the coverage status. +// +// ponytail: name match, not symbol resolution. Two files declaring the same +// type name both become targets; a mention inside a comment or string counts. +// Upgrade path is a SourceKit or swift-syntax pass if precision matters. +func addSwiftTypeReferenceEdges(absRoot string, analyses []FileAnalysis, fg *FileGraph) bool { + declaredBy := make(map[string][]string) + for _, a := range analyses { + if a.Language != "swift" { + continue + } + for _, name := range a.Types { + if len(name) < 3 { + continue + } + declaredBy[name] = append(declaredBy[name], a.Path) + } + } + if len(declaredBy) == 0 { + return false + } + for _, a := range analyses { + if a.Language != "swift" { + continue + } + data, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(a.Path))) + if err != nil { + continue + } + seen := make(map[string]bool) + var targets []string + for _, ident := range swiftIdentifier.FindAll(swiftImportLine.ReplaceAll(data, nil), -1) { + for _, target := range declaredBy[string(ident)] { + if target == a.Path || seen[target] || slices.Contains(fg.Imports[a.Path], target) { + continue + } + seen[target] = true + targets = append(targets, target) + } + } + if len(targets) == 0 { + continue + } + sort.Strings(targets) + fg.Imports[a.Path] = append(fg.Imports[a.Path], targets...) + for _, target := range targets { + fg.Importers[target] = append(fg.Importers[target], a.Path) + } + } + return true +} + // sortEdges orders the reverse edge lists. Importers are appended while // iterating analyses, whose order the scanner does not fix, so the same // repository scanned twice produced the same importers in a different diff --git a/scanner/importmodel.go b/scanner/importmodel.go index 93fcc24..fe42e90 100644 --- a/scanner/importmodel.go +++ b/scanner/importmodel.go @@ -2,6 +2,7 @@ package scanner import ( "fmt" + "slices" "sort" "strings" @@ -33,6 +34,11 @@ var symbolLevelImportLanguages = map[string]string{ // decide whether to trust a zero-dependent answer. const symbolLevelCoverageNote = "imports name modules, not files, and same-package files need no import: intra-project edges need symbol-reference resolution and are not represented" +// swiftTypeReferenceNote explains what the Swift type-name resolver stands +// behind and what it cannot see, so a consumer reading a Swift importer list +// knows it is name-matched rather than compiler-resolved. +const swiftTypeReferenceNote = "intra-project edges resolved by type-name reference; extensions, same-name types, and protocol-only or generic references may be missed" + // ResolvesFileLevelImports reports whether import resolution can produce // file-to-file edges for a language. func ResolvesFileLevelImports(language string) bool { @@ -83,7 +89,7 @@ func symbolLevelInventory(files []FileInfo) map[string]int { // symbolLevelSources renders one source per symbol-level language present, so // the source list names which slice of the project the graph cannot see rather // than emitting a bare "partial" with nothing to point at. -func symbolLevelSources(counts map[string]int) []analysis.Source { +func symbolLevelSources(counts map[string]int, resolved map[string]bool) []analysis.Source { if len(counts) == 0 { return nil } @@ -95,10 +101,14 @@ func symbolLevelSources(counts map[string]int) []analysis.Source { sources := make([]analysis.Source, 0, len(displays)) for _, display := range displays { + status, note := analysis.SourceUnavailable, symbolLevelCoverageNote + if resolved[display] { + status, note = analysis.SourceMixed, swiftTypeReferenceNote + } sources = append(sources, analysis.Source{ Name: "symbol-imports/" + strings.ToLower(display), - Status: analysis.SourceUnavailable, - Detail: fmt.Sprintf("%s (%d files): %s", display, counts[display], symbolLevelCoverageNote), + Status: status, + Detail: fmt.Sprintf("%s (%d files): %s", display, counts[display], note), }) } return sources @@ -114,7 +124,7 @@ func symbolLevelSources(counts map[string]int) []analysis.Source { // Coverage that is already partial or unavailable keeps its status; this only // ever removes confidence. func ApplySymbolLevelImportCoverage(coverage analysis.Coverage, files []FileInfo) analysis.Coverage { - sources := symbolLevelSources(symbolLevelInventory(files)) + sources := symbolLevelSources(symbolLevelInventory(files), nil) if len(sources) == 0 { return coverage } @@ -131,24 +141,25 @@ func (c *GraphCoverage) AddSymbolLevelImportCoverage(files []FileInfo) { if c == nil { return } - c.addSymbolLevelImportCoverage(symbolLevelInventory(files)) + c.addSymbolLevelImportCoverage(symbolLevelInventory(files), nil) } -func (c *GraphCoverage) addSymbolLevelImportCoverage(counts map[string]int) { +// resolved names the display languages whose intra-project edges a symbol +// resolver produced; they are reported mixed rather than unavailable. +func (c *GraphCoverage) addSymbolLevelImportCoverage(counts map[string]int, resolved map[string]bool) { if c == nil { return } - sources := symbolLevelSources(counts) + sources := symbolLevelSources(counts, resolved) if len(sources) == 0 { return } c.Sources = append(c.Sources, sources...) - displays := make([]string, 0, len(counts)) - for display := range counts { - displays = append(displays, display) + for _, source := range sources { + if !slices.Contains(c.Notes, source.Detail) { + c.Notes = append(c.Notes, source.Detail) + } } - sort.Strings(displays) - c.Notes = append(c.Notes, fmt.Sprintf("%s: %s", strings.Join(displays, ", "), symbolLevelCoverageNote)) if c.Status == "" || c.Status == analysis.CoverageComplete { c.Status = analysis.CoveragePartial } diff --git a/scanner/importmodel_test.go b/scanner/importmodel_test.go index fc729cd..c7a4904 100644 --- a/scanner/importmodel_test.go +++ b/scanner/importmodel_test.go @@ -9,8 +9,10 @@ import ( ) // A Swift project is the clearest case of a language whose files never import -// each other: the graph is structurally empty, so reporting complete coverage -// tells a consumer a change is isolated when nothing checked that. +// each other. Type-name reference resolution recovers the edges, but it is a +// name match rather than compiler resolution, so coverage must never read as +// complete: a consumer would conclude a change is isolated when nothing +// checked that. func TestSwiftFixtureNeverReportsCompleteCoverage(t *testing.T) { graph, err := BuildFileGraph(context.Background(), "../testdata/symbol-imports-swift", Filters{}) if err != nil { @@ -19,16 +21,28 @@ func TestSwiftFixtureNeverReportsCompleteCoverage(t *testing.T) { if graph.Coverage.Status == analysis.CoverageComplete { t.Fatalf("swift fixture coverage = %q, want anything but complete", graph.Coverage.Status) } - if edges := len(graph.Imports) + len(graph.Importers); edges != 0 { - t.Fatalf("swift fixture produced %d edges, want 0 (the fixture has no file-level imports)", edges) + // Same-module files never import each other, so every edge here comes + // from type-name reference resolution: B.swift mentions a type A.swift + // declares, so B -> A. Coverage stays partial and the source says mixed. + if got := graph.Imports["Sources/App/UserViewModel.swift"]; len(got) != 1 || got[0] != "Sources/App/Models.swift" { + t.Fatalf("UserViewModel.swift imports = %v, want exactly [Sources/App/Models.swift] (it mentions User)", got) + } + if got := graph.Imports["Sources/App/ContentView.swift"]; len(got) != 1 || got[0] != "Sources/App/UserViewModel.swift" { + t.Fatalf("ContentView.swift imports = %v, want exactly [Sources/App/UserViewModel.swift]", got) + } + if got := graph.Imports["Sources/App/Models.swift"]; len(got) != 0 { + t.Fatalf("Models.swift imports = %v, want none (it references no project type)", got) } var named bool for _, source := range graph.Coverage.Sources { if source.Name == "symbol-imports/swift" { named = true - if !strings.Contains(source.Detail, "Swift (3 files)") { - t.Fatalf("source detail = %q, want it to name the language and file count", source.Detail) + if source.Status != analysis.SourceMixed { + t.Fatalf("symbol-imports/swift status = %q, want mixed once type-name edges are resolved", source.Status) + } + if !strings.Contains(source.Detail, "Swift (3 files)") || !strings.Contains(source.Detail, "type-name reference") { + t.Fatalf("source detail = %q, want it to name the language, file count and resolver", source.Detail) } } } @@ -144,8 +158,8 @@ func TestFixtureImporterListsAreExact(t *testing.T) { if err != nil { t.Fatalf("build swift fixture graph: %v", err) } - if got := swift.Importers["Sources/App/Models.swift"]; len(got) != 0 { - t.Fatalf("swift Models.swift importers = %v, want none (no file-level import exists to find)", got) + if got := swift.Importers["Sources/App/Models.swift"]; len(got) != 1 || got[0] != "Sources/App/UserViewModel.swift" { + t.Fatalf("swift Models.swift importers = %v, want exactly [Sources/App/UserViewModel.swift] (the one file that mentions User)", got) } golang, err := BuildFileGraph(context.Background(), "../testdata/file-imports-go", Filters{}) @@ -171,3 +185,26 @@ func TestEffectiveStatusSpellsOutComplete(t *testing.T) { } } } + +// The declaration head decides the name: "class func" is a static method, not +// a type, and an extension declares nothing new. +func TestExtractTypeName(t *testing.T) { + cases := map[string]string{ + "public final class Foo: Bar { }": "Foo", + "struct Baz: Codable { }": "Baz", + "indirect enum Qux { case a }": "Qux", + "actor Act { }": "Act", + "protocol Proto: AnyObject { }": "Proto", + "@objc class Obj: NSObject { }": "Obj", + "@available(iOS 15, *) struct Av { }": "Av", + "extension Foo { }": "", + "class func shared() -> Foo { }": "", + "class var count: Int { 0 }": "", + "class Outer {\n class func f() {}\n}": "Outer", + } + for text, want := range cases { + if got := extractTypeName(text); got != want { + t.Errorf("extractTypeName(%q) = %q, want %q", text, got, want) + } + } +} diff --git a/scanner/sg-rules/swift.yml b/scanner/sg-rules/swift.yml index 3d92cae..c8c3ffe 100644 --- a/scanner/sg-rules/swift.yml +++ b/scanner/sg-rules/swift.yml @@ -7,3 +7,10 @@ id: swift-functions language: swift rule: kind: function_declaration +--- +id: swift-types +language: swift +rule: + any: + - kind: class_declaration + - kind: protocol_declaration diff --git a/scanner/types.go b/scanner/types.go index 47b0ddd..ee834be 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -40,6 +40,7 @@ type FileAnalysis struct { Language string `json:"language"` Package string `json:"-"` Functions []string `json:"functions"` + Types []string `json:"types,omitempty"` Imports []string `json:"imports"` References []ImportReference `json:"-"` } @@ -135,11 +136,13 @@ func newDepsProjectWithFilters(root string, files []FileAnalysis, externalDeps m if files[index].Functions == nil { files[index].Functions = []string{} } + files[index].Types = slices.Clone(files[index].Types) files[index].Imports = slices.Clone(files[index].Imports) if files[index].Imports == nil { files[index].Imports = []string{} } slices.Sort(files[index].Functions) + slices.Sort(files[index].Types) slices.Sort(files[index].Imports) } sort.Slice(files, func(i, j int) bool { From 756cde111a5dfb5646f5c145c07ff6e9474cda92 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 11 Sep 2026 14:35:01 -0400 Subject: [PATCH 3/5] feat(config): auto-init project config and escalate unfinished setup Every analysis entry point (tree, deps, importers, blast-radius, collide, session-start and prompt-submit hooks) now writes an auto-detected .codemap/config.json when none exists and the root is a git checkout. Remote clones and non-git roots are never written. config init now excludes noise that is actually present (Carthage, coverage, fixtures, __snapshots__, .xcassets, and binary asset extensions in volume), keeps those extensions out of `only`, and writes mode explicitly so a fresh init assesses as ready instead of boilerplate. When setup is still missing, empty, malformed, or boilerplate, hooks print an imperative stop line and a Run now instruction; the config-setup skill opens with the fix-first loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- blast_radius.go | 1 + cmd/config.go | 74 +++++++++++++++++++++++++++++++++- cmd/config_more_test.go | 47 +++++++++++++++++++++ cmd/hooks.go | 6 ++- cmd/hooks_more_test.go | 19 ++++++++- collide.go | 1 + main.go | 3 ++ skills/builtin/config-setup.md | 11 +++++ 8 files changed, 157 insertions(+), 5 deletions(-) diff --git a/blast_radius.go b/blast_radius.go index 34f3f3f..aa60def 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -442,6 +442,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas return blastRadiusBundle{}, &blastRadiusDiffError{ref: ref, err: err} } + cmd.EnsureProjectConfig(absRoot) cfg := config.Load(absRoot) filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude} gitCache := scanner.NewGitIgnoreCache(absRoot) diff --git a/cmd/config.go b/cmd/config.go index bb39dbd..541d4a4 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -19,10 +19,74 @@ var errConfigExists = errors.New("config already exists") type configInitResult struct { Path string TopExts []string + Exclude []string TotalFiles int MatchedFiles int } +// noiseDirs are directories that are never project source and that the +// scanner does not already skip (Pods, build, dist, vendor, node_modules, +// target, DerivedData, testdata live in scanner.IgnoredDirs). Init excludes +// one only when it exists at the root or one level down, so a config never +// carries excludes for noise the repo does not have. +var noiseDirs = []string{"Carthage", "coverage", "fixtures", "__snapshots__", ".xcassets"} + +// noiseExts are binary assets excluded when a repo carries them in volume. +var noiseExts = []string{"png", "jpg", "gif", "pdf", "otf", "ttf", "mp3", "mp4", "zip"} + +const noiseExtThreshold = 10 + +// detectNoise returns exclude patterns for noise that is present in files. +func detectNoise(files []scanner.FileInfo) []string { + dirs := make(map[string]bool) + extCount := make(map[string]int) + for _, f := range files { + parts := strings.Split(filepath.ToSlash(f.Path), "/") + for i := 0; i < len(parts)-1 && i < 2; i++ { + for _, noise := range noiseDirs { + if parts[i] == noise || (strings.HasPrefix(noise, ".") && strings.HasSuffix(parts[i], noise)) { + dirs[noise] = true + } + } + } + extCount[strings.TrimPrefix(strings.ToLower(f.Ext), ".")]++ + } + var out []string + for _, noise := range noiseDirs { + if dirs[noise] { + out = append(out, noise) + } + } + for _, ext := range noiseExts { + if extCount[ext] >= noiseExtThreshold { + out = append(out, "."+ext) + } + } + return out +} + +// EnsureProjectConfig writes an auto-detected .codemap/config.json when none +// exists and root is a git checkout. Every analysis entry point calls this so +// a repo never stays in the "missing" state past its first codemap command. +// Write failures (read-only checkout) are silent: the caller still runs. +func EnsureProjectConfig(root string) bool { + if _, err := os.Stat(config.ConfigPath(root)); err == nil { + return false + } + if _, err := os.Stat(filepath.Join(root, ".git")); err != nil { + return false + } + if _, err := initProjectConfig(root); err != nil { + return false + } + // Only a person at a terminal needs the notice; piped consumers that + // merge stderr into a JSON stream must not see it. + if info, err := os.Stderr.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + fmt.Fprintln(os.Stderr, "codemap: wrote .codemap/config.json (auto-detected); tune with: codemap skill show config-setup") + } + return true +} + // nonCodeExtensions are extensions excluded from "config init" auto-detection. // These are documentation, data, or lock files that rarely represent the // project's primary code. @@ -37,6 +101,8 @@ var nonCodeExtensions = map[string]bool{ "log": true, "jsonl": true, "pid": true, "tmp": true, "bak": true, "out": true, "cache": true, "swp": true, "gitignore": true, "gitattributes": true, "editorconfig": true, + // Binary assets: an `only` slot for these contradicts the noise excludes. + "otf": true, "pdf": true, "mp3": true, "mp4": true, "zip": true, } // RunConfig dispatches the "config" subcommand. @@ -86,6 +152,9 @@ func configInit(root string) { fmt.Println("No code extensions detected — wrote empty config.") } else { fmt.Printf(" only: %s\n", strings.Join(result.TopExts, ", ")) + if len(result.Exclude) > 0 { + fmt.Printf(" exclude: %s\n", strings.Join(result.Exclude, ", ")) + } if result.TotalFiles > 0 { fmt.Printf(" (%d of %d files)\n", result.MatchedFiles, result.TotalFiles) } @@ -138,7 +207,10 @@ func initProjectConfig(root string) (configInitResult, error) { result.TopExts = append(result.TopExts, e.Ext) } - cfg := config.ProjectConfig{Only: result.TopExts} + // Mode is written explicitly: an init that looked for noise is a + // decision, not a bootstrap, so the config assesses as ready. + result.Exclude = detectNoise(files) + cfg := config.ProjectConfig{Only: result.TopExts, Exclude: result.Exclude, Mode: "auto"} if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil { return result, fmt.Errorf("create .codemap directory: %w", err) diff --git a/cmd/config_more_test.go b/cmd/config_more_test.go index d3df180..0872c1f 100644 --- a/cmd/config_more_test.go +++ b/cmd/config_more_test.go @@ -177,3 +177,50 @@ func TestInitProjectConfigSkipsNoiseExtensions(t *testing.T) { t.Fatalf("TopExts = %v, want [go]", result.TopExts) } } + +func TestInitProjectConfigExcludesPresentNoise(t *testing.T) { + root := t.TempDir() + mustWriteConfigFixture(t, filepath.Join(root, "App", "main.swift"), "import UIKit\n") + mustWriteConfigFixture(t, filepath.Join(root, "App", "view.swift"), "import UIKit\n") + mustWriteConfigFixture(t, filepath.Join(root, "Carthage", "Build", "Alamofire.swift"), "import Foundation\n") + for i := 0; i < 20; i++ { + mustWriteConfigFixture(t, filepath.Join(root, "App", "Assets.xcassets", "icon"+strings.Repeat("x", i)+".png"), "png\n") + } + + result, err := initProjectConfig(root) + if err != nil { + t.Fatalf("initProjectConfig: %v", err) + } + if got, want := strings.Join(result.Exclude, ","), "Carthage,.xcassets,.png"; got != want { + t.Fatalf("Exclude = %q, want %q", got, want) + } + if got := config.AssessSetup(root).State; got != config.SetupStateReady { + t.Fatalf("state after init = %q, want ready", got) + } +} + +func TestEnsureProjectConfigWritesOnlyInsideRepos(t *testing.T) { + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + mustWriteConfigFixture(t, filepath.Join(repo, "main.go"), "package main\n") + if !EnsureProjectConfig(repo) { + t.Fatal("expected config to be written in a repo") + } + if _, err := os.Stat(config.ConfigPath(repo)); err != nil { + t.Fatalf("config not written: %v", err) + } + if EnsureProjectConfig(repo) { + t.Fatal("second call must not rewrite an existing config") + } + + plain := t.TempDir() + mustWriteConfigFixture(t, filepath.Join(plain, "main.go"), "package main\n") + if EnsureProjectConfig(plain) { + t.Fatal("must not write config outside a repo") + } + if _, err := os.Stat(config.ConfigPath(plain)); !os.IsNotExist(err) { + t.Fatalf("config unexpectedly present in non-repo: %v", err) + } +} diff --git a/cmd/hooks.go b/cmd/hooks.go index a39598b..4e08c5e 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -412,6 +412,7 @@ func hookSessionStart(root string) error { state = waitForDaemonState(root, 2*time.Second) } fileCount, fileCountKnown = configuredStateFileCount(root, state) + EnsureProjectConfig(root) projCfg := config.Load(root) structureBudget := projCfg.SessionStartOutputBytes() maxHubs := projCfg.HubDisplayLimit() @@ -887,6 +888,7 @@ func hookPromptSubmit(root string) error { return nil } + EnsureProjectConfig(root) projCfg := config.Load(root) topK := projCfg.RoutingTopKOrDefault() info := getValidatedHubInfo(root) @@ -1195,11 +1197,11 @@ func showConfigSetupHint(root string) { fmt.Printf("\n", string(data)) } - fmt.Println("⚙️ Codemap config setup recommended:") + fmt.Println("🛑 Codemap setup is not finished. Before continuing the user's task: run `codemap skill show config-setup`, tune .codemap/config.json, rerun `codemap .`, then proceed.") for _, reason := range assessment.Reasons { fmt.Printf(" • %s\n", reason) } - fmt.Println(" • Run `codemap skill show config-setup` and tune `.codemap/config.json` before deeper analysis.") + fmt.Println("Run now: codemap skill show config-setup") fmt.Println() } diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 5f74d91..a7393ce 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -573,8 +573,11 @@ func TestShowConfigSetupHint(t *testing.T) { if !strings.Contains(out, "codemap:config") { t.Fatalf("expected config marker, got:\n%s", out) } - if !strings.Contains(out, "config setup recommended") { - t.Fatalf("expected setup heading, got:\n%s", out) + if !strings.Contains(out, "🛑 Codemap setup is not finished. Before continuing the user's task") { + t.Fatalf("expected escalated setup heading, got:\n%s", out) + } + if !strings.Contains(out, "Run now: codemap skill show config-setup") { + t.Fatalf("expected run-now line, got:\n%s", out) } if !strings.Contains(out, "config-setup") { t.Fatalf("expected config-setup guidance, got:\n%s", out) @@ -594,6 +597,18 @@ func TestShowConfigSetupHint(t *testing.T) { t.Fatalf("expected no output for ready config, got:\n%s", out) } }) + + t.Run("boilerplate config escalates", func(t *testing.T) { + root := t.TempDir() + writeProjectConfig(t, root, config.ProjectConfig{Only: []string{"go"}}) + out := captureOutput(func() { showConfigSetupHint(root) }) + if !strings.Contains(out, "🛑 Codemap setup is not finished. Before continuing the user's task") { + t.Fatalf("expected escalated line for boilerplate config, got:\n%s", out) + } + if !strings.Contains(out, `"state":"boilerplate"`) { + t.Fatalf("expected boilerplate marker, got:\n%s", out) + } + }) } func TestFindChildReposAndSessionStartVariants(t *testing.T) { diff --git a/collide.go b/collide.go index 9b20185..3fd42e6 100644 --- a/collide.go +++ b/collide.go @@ -233,6 +233,7 @@ func runCollideSubcommand(args []string, launchDir string) int { return 1 } + cmd.EnsureProjectConfig(resolvedRoot) cfg := config.Load(resolvedRoot) filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude} // The scan outcome is kept rather than discarded: a package-resolved diff --git a/main.go b/main.go index f784c5c..9d8aa71 100644 --- a/main.go +++ b/main.go @@ -395,6 +395,9 @@ func main() { } // Load project config (CLI flags take precedence) + if tempDir == "" { + cmd.EnsureProjectConfig(absRoot) + } projCfg := config.Load(absRoot) if len(only) == 0 && len(projCfg.Only) > 0 { only = projCfg.Only diff --git a/skills/builtin/config-setup.md b/skills/builtin/config-setup.md index e9649fd..55b92ee 100644 --- a/skills/builtin/config-setup.md +++ b/skills/builtin/config-setup.md @@ -8,6 +8,17 @@ languages: ["go", "typescript", "javascript", "python", "rust", "ruby", "swift", # Codemap Config Setup +## When this skill is matched at score 100, do this BEFORE the user's task, then continue the task + +A score of 100 means a hook found the config missing, empty, malformed, or boilerplate. Fix it first; it takes under a minute and every later Codemap answer depends on it. + +1. Run `codemap config show` to see the current state. If it says missing, run `codemap config init` (most commands now do this for you). +2. Write or tune `.codemap/config.json` using the workflow below: keep source extensions in `only`, add `exclude` for real noise, set `depth` if the tree is wide. +3. Rerun `codemap .` and confirm the tree is dominated by source, not assets. +4. Verify one known cross-file dependency still resolves with `codemap --importers `. + +Then return to the user's task. Do not skip this because the task looks small. + ## Goal Write or improve `.codemap/config.json` so future Codemap calls stay focused on the code that matters for this repo. From f4b706b543e635e854c65e88a40f821bf7d8bdb8 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 11 Sep 2026 14:37:41 -0400 Subject: [PATCH 4/5] fix(find,hooks): index Swift types, accept trailing flags, escalate setup in prompt-submit - find now tokenizes FileAnalysis.Types so Swift type names rank - find auto-inits config like the other analysis entry points - codemap find "q" --limit N works with flags after the query - prompt-submit prints the stop line when config-setup is injected at 100 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- cmd/hooks.go | 6 ++++++ find/find.go | 4 ++-- find_cmd.go | 23 ++++++++++++++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmd/hooks.go b/cmd/hooks.go index 4e08c5e..f648ce5 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -1031,6 +1031,12 @@ func showMatchedSkills(root string, intent TaskIntent) { names[i] = r.Name } fmt.Printf("Skills matched: %s — run `codemap skill show ` for guidance\n", strings.Join(names, ", ")) + for _, r := range refs { + if r.Name == "config-setup" && strings.HasPrefix(r.Reason, "config:") { + fmt.Println("🛑 Codemap setup is not finished. Before continuing the user's task: run `codemap skill show config-setup`, tune .codemap/config.json, rerun `codemap .`, then proceed.") + break + } + } } type codemapNextStep struct { diff --git a/find/find.go b/find/find.go index 61b02d5..8f3316d 100644 --- a/find/find.go +++ b/find/find.go @@ -81,7 +81,7 @@ func Rank(analyses []scanner.FileAnalysis, query string, limit int) []Hit { df := make(map[string]int) totalLen := 0 for i, a := range analyses { - docs[i] = tokenize(strings.Join(append([]string{a.Path}, a.Functions...), " ")) + docs[i] = tokenize(strings.Join(append(append([]string{a.Path}, a.Functions...), a.Types...), " ")) totalLen += len(docs[i]) for _, tok := range unique(docs[i]) { df[tok]++ @@ -197,7 +197,7 @@ func names(a scanner.FileAnalysis) []string { if i := strings.LastIndexAny(base, `/\`); i >= 0 { base = base[i+1:] } - return append([]string{base}, a.Functions...) + return append(append([]string{base}, a.Functions...), a.Types...) } // hasWord reports whether q is one of name's split words (loadTheme has diff --git a/find_cmd.go b/find_cmd.go index 57d423e..e5d1c08 100644 --- a/find_cmd.go +++ b/find_cmd.go @@ -31,13 +31,25 @@ func runFindSubcommand(args []string, launchDir string) int { fmt.Fprintln(os.Stderr, "Options:") fs.PrintDefaults() } - if err := fs.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return 0 + // Flags may follow the query (`codemap find "theme" --limit 3`), so keep + // parsing until only positional words remain. + var words []string + rest := args + for { + if err := fs.Parse(rest); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 } - return 2 + rest = fs.Args() + if len(rest) == 0 { + break + } + words = append(words, rest[0]) + rest = rest[1:] } - query := strings.TrimSpace(strings.Join(fs.Args(), " ")) + query := strings.TrimSpace(strings.Join(words, " ")) if query == "" { fmt.Fprintln(os.Stderr, "Error: codemap find needs a query") return 2 @@ -59,6 +71,7 @@ func runFindSubcommand(args []string, launchDir string) int { return 1 } + cmd.EnsureProjectConfig(absRoot) cfg := config.Load(absRoot) report, err := find.Run(context.Background(), absRoot, scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude}, query, *limit) if err != nil { From 7fe899d844485d6b28304c43e98fc997d1bcf41c Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 11 Sep 2026 17:11:09 -0400 Subject: [PATCH 5/5] docs: the output standard every printed line is reviewed against Sourced, precise, bounded, useful. Linked from CONTRIBUTING. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- CONTRIBUTING.md | 5 +++++ docs/OUTPUT-STANDARD.md | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 docs/OUTPUT-STANDARD.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0fcf42..325c2f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,6 +92,11 @@ make deps - Run `go fmt` before committing - Test your changes with `./codemap` on a real project + +## Every line codemap prints + +Output is reviewed against [docs/OUTPUT-STANDARD.md](docs/OUTPUT-STANDARD.md): sourced, precise, bounded, useful. A PR that adds or changes a printed line quotes it and says which tests it passes. + ## Questions? Open an issue or reach out. We're happy to help! diff --git a/docs/OUTPUT-STANDARD.md b/docs/OUTPUT-STANDARD.md new file mode 100644 index 0000000..6e252b2 --- /dev/null +++ b/docs/OUTPUT-STANDARD.md @@ -0,0 +1,22 @@ +# Output standard + +Every line codemap prints, posts, or renders is reviewed against four tests. A line that fails one does not ship. This applies to CLI output, hook output, MCP text, PR comments, the ledger, and any surface that renders codemap facts. + +## The four tests + +1. **Sourced.** The line names the path, line, PR, commit, or the command that reproduces it. If it cannot cite its source, it is an opinion, and codemap does not print opinions. +2. **Precise.** Numbers and names. Never "may", "might", "likely", "probably". +3. **Bounded.** The line states its own limit in the same breath: the coverage status, the cap that was hit, what was skipped. +4. **Useful.** The line names what to look at or what to do next. A fact with no next action is noise and is cut. + +## Consequences + +- A heuristic label does not ship until it is measured. A pair's "likelihood" is written to the calibration log and never printed. The printed line is the fact: `shared: src/email/emailService.ts (32 importers)`. +- An interpretation label comes after the number and states its threshold: `src/email/emailService.ts · 32 importers · blast high (high = 9+ importers or 2 hubs)`. +- No model writes a fact line. Agents consume codemap; they never produce its output. +- Third-party statistics do not appear on any codemap surface. Numbers from the user's own repositories do. +- Green says what it did not test. A passing result lists what was skipped. + +## Review rule + +A pull request that adds or changes a printed line quotes the new line in its description and says which of the four tests it passes and how. A reviewer who cannot find the source for a line asks for it or asks for the line to be removed.