From ad45eaef7141b593bcf1ad8fd3ec064ba454cd6b Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 11 Nov 2026 13:49:36 -0500 Subject: [PATCH 1/3] fix(tree): star marks import hubs, not the largest files; add legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree starred the five largest source files with no legend, so a reader could not tell what the star meant, and on this repo it landed on main_more_test.go. File size says nothing about blast radius. The star now marks hubs from the file graph, with the importer count inline: `types.ts ←41` for a file, `scanner/ ←73` on the directory of a multi-file Go package (Go imports name packages, so the count belongs to the package, per #292). N is the importer count used by get_hubs and the session-start hub list; a hub needs 3+ non-test importers, so test files are never starred. One legend line under the tree says what the marker and N mean, and carries the graph note: partial coverage, or why stars are missing (no graph, or the repo is above the 5000-file graph limit). Hubs come from the daemon's validated graph when it was built with the same filters, else a fresh BuildFileGraph scan (docling, no daemon: 0.41s -> 1.8s per `codemap .`; codemap with a warm daemon: no measurable change). The session-start hook drops its own hub block when the tree carries the legend, so hubs are stated once and the werkzeug tree still fits the 10,000 char cap at full depth. --json, --deps, --skyline and --diff output are unchanged. MCP get_structure renders without hub data, so its tree loses the old size stars and gains no legend; its HUB FILES section is unchanged. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01FWQw8DzwM4bQnL7mvk6Fsq --- .claude/skills/codemap/SKILL.md | 2 +- cmd/hooks.go | 32 ++++++++- cmd/hooks_tree_hubs_test.go | 9 +++ main.go | 3 + mcp/main.go | 2 +- plugins/codemap/skills/codemap/SKILL.md | 2 +- render/tree.go | 92 +++++++++++++++---------- render/tree_hubs_test.go | 72 +++++++++++++++++++ render/tree_render_test.go | 8 +-- render/tree_test.go | 92 ------------------------- scanner/types.go | 4 ++ skills/builtin/codemap.md | 2 +- tree_hubs.go | 48 +++++++++++++ 13 files changed, 228 insertions(+), 140 deletions(-) create mode 100644 cmd/hooks_tree_hubs_test.go create mode 100644 render/tree_hubs_test.go create mode 100644 tree_hubs.go diff --git a/.claude/skills/codemap/SKILL.md b/.claude/skills/codemap/SKILL.md index 26eda6b7..53720a03 100644 --- a/.claude/skills/codemap/SKILL.md +++ b/.claude/skills/codemap/SKILL.md @@ -122,7 +122,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ## Output Interpretation ### Tree View (`codemap .`) -- Stars (⭐) indicate top 5 largest source files +- `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so - Directories flattened when containing single subdirectory ### Dependency Flow (`codemap --deps`) diff --git a/cmd/hooks.go b/cmd/hooks.go index 9beca807..aae2cf24 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -447,7 +447,7 @@ func hookSessionStart(root string) error { return err } - trailer, err := captureStdout(func() error { + hubBlock, err := captureStdout(func() error { // Show hub files (from daemon if running, otherwise fresh scan) info := getHubInfo(root) if info != nil && len(info.Hubs) > 0 { @@ -467,6 +467,13 @@ func hookSessionStart(root string) error { } else if shouldSkipHubAnalysis(fileCount, fileCountKnown) { fmt.Printf("ℹ️ Hub analysis skipped for large repo (%d files)\n", fileCount) } + return nil + }) + if err != nil { + return err + } + + trailer, err := captureStdout(func() error { currentBranch, branchKnown := gitCurrentBranch(root) recentHandoff := getRecentHandoff(root) @@ -496,7 +503,11 @@ func hookSessionStart(root string) error { depth = projCfg.Depth } exe, exeErr := os.Executable() + rendered := map[int]string{} renderTree := func(depth int) string { + if out, ok := rendered[depth]; ok { + return out + } if exeErr != nil { return "" } @@ -513,7 +524,14 @@ func hookSessionStart(root string) error { cmd.Stdout = &buf cmd.Stderr = os.Stderr cmd.Run() - return buf.String() + "\n" + rendered[depth] = buf.String() + "\n" + return rendered[depth] + } + + // A tree with its own hub legend already marks every hub with its count; + // the hook's hub list would state them twice. + if !treeStatesHubs(renderTree(depth)) { + trailer = hubBlock + trailer } fmt.Print(fitSessionStart(header, trailer, depth, structureBudget, sessionStartOutputCap(), renderTree)) @@ -521,6 +539,16 @@ func hookSessionStart(root string) error { return nil } +// Markers of the tree's hub legend (render.printHubLegend). +const ( + treeHubLegend = "⭐️ ←N = " + treeNoHubLegend = "No ⭐️ hub marks:" +) + +func treeStatesHubs(tree string) bool { + return strings.Contains(tree, treeHubLegend) || strings.Contains(tree, treeNoHubLegend) +} + // sessionStartOutputCap is the whole session-start budget; CODEMAP_HOOK_OUTPUT_CAP // overrides it for hosts with a different limit. func sessionStartOutputCap() int { diff --git a/cmd/hooks_tree_hubs_test.go b/cmd/hooks_tree_hubs_test.go new file mode 100644 index 00000000..ab7eb87f --- /dev/null +++ b/cmd/hooks_tree_hubs_test.go @@ -0,0 +1,9 @@ +package cmd + +import "testing" + +func TestTreeStatesHubs(t *testing.T) { + if !treeStatesHubs("tree\n⭐️ ←N = imported by N files\n") || !treeStatesHubs("No ⭐️ hub marks: x") || treeStatesHubs("plain tree\n") { + t.Fatal("hub legend detection wrong") + } +} diff --git a/main.go b/main.go index 8cec3460..57c56c71 100644 --- a/main.go +++ b/main.go @@ -622,6 +622,9 @@ func main() { } else if *skylineMode { render.Skyline(os.Stdout, project, *animateMode) } else { + if diffInfo == nil { + project.Hubs, project.HubNote = treeHubs(absRoot, len(files), filters) + } render.Tree(os.Stdout, project) } if !*jsonMode && scanBound != nil { diff --git a/mcp/main.go b/mcp/main.go index 73fea117..4a1afee5 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -239,7 +239,7 @@ func NewServer(options RuntimeOptions) *mcp.Server { // Tool: get_structure - Get project tree view mcp.AddTool(server, &mcp.Tool{ Name: "get_structure", - Description: "Get the project structure as a tree view. Shows files organized by directory with language detection, file sizes, and highlights the top 5 largest source files. Use this to understand how a codebase is organized.", + Description: "Get the project structure as a tree view. Shows files organized by directory with language detection, and file sizes. Use this to understand how a codebase is organized.", OutputSchema: mustSchemaFor[StructureOutput](), }, handleGetStructure) diff --git a/plugins/codemap/skills/codemap/SKILL.md b/plugins/codemap/skills/codemap/SKILL.md index 26eda6b7..53720a03 100644 --- a/plugins/codemap/skills/codemap/SKILL.md +++ b/plugins/codemap/skills/codemap/SKILL.md @@ -122,7 +122,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ## Output Interpretation ### Tree View (`codemap .`) -- Stars (⭐) indicate top 5 largest source files +- `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so - Directories flattened when containing single subdirectory ### Dependency Flow (`codemap --deps`) diff --git a/render/tree.go b/render/tree.go index 8c528d76..dccfd491 100644 --- a/render/tree.go +++ b/render/tree.go @@ -13,6 +13,7 @@ import ( // treeNode represents a node in the file tree type treeNode struct { name string + path string // slash path from the project root isFile bool file *scanner.FileInfo children map[string]*treeNode @@ -21,36 +22,23 @@ type treeNode struct { statsReady bool } -// getTopLargeFiles returns paths of top 5 largest source code files -func getTopLargeFiles(files []scanner.FileInfo) map[string]bool { - // Filter out assets and binaries (no extension = likely binary) - var largest []scanner.FileInfo - for _, f := range files { - ext := strings.ToLower(f.Ext) - // Skip if no extension (likely binary) or if it's an asset - if ext == "" || IsAssetExtension(ext) { - continue - } - position := sort.Search(len(largest), func(i int) bool { - return largest[i].Size < f.Size || largest[i].Size == f.Size && largest[i].Path > f.Path - }) - if position >= 5 { - continue - } - largest = append(largest, scanner.FileInfo{}) - copy(largest[position+1:], largest[position:]) - largest[position] = f - if len(largest) > 5 { - largest = largest[:5] - } - } +// hubMarks maps tree paths to importer counts: single-file hubs by file +// path, multi-file Go packages by directory path. +type hubMarks struct { + files map[string]int + dirs map[string]int +} - // Return top 5 as set - result := make(map[string]bool) - for _, file := range largest { - result[file.Path] = true +func newHubMarks(hubs []scanner.HubEntry) hubMarks { + m := hubMarks{files: map[string]int{}, dirs: map[string]int{}} + for _, h := range hubs { + if h.IsPackage() { + m.dirs[strings.TrimSuffix(h.Path, "/")] = h.Importers + } else { + m.files[h.Path] = h.Importers + } } - return result + return m } // getDirStats recursively calculates file count and total size @@ -99,6 +87,7 @@ func buildTreeStructure(files []scanner.FileInfo) *treeNode { // File current.children[remaining] = &treeNode{ name: remaining, + path: filepath.ToSlash(f.Path), isFile: true, file: f, } @@ -106,7 +95,7 @@ func buildTreeStructure(files []scanner.FileInfo) *treeNode { } part := remaining[:separator] if current.children[part] == nil { - current.children[part] = &treeNode{name: part, children: make(map[string]*treeNode)} + current.children[part] = &treeNode{name: part, path: strings.TrimPrefix(current.path+"/"+part, "/"), children: make(map[string]*treeNode)} } current = current.children[part] remaining = remaining[separator+1:] @@ -172,9 +161,6 @@ func Tree(w io.Writer, project scanner.Project) { exts = exts[:5] } - // Get top large files - topLarge := getTopLargeFiles(files) - // Build extension line first to calculate width var extLine string if len(exts) > 0 { @@ -238,7 +224,10 @@ func Tree(w io.Writer, project scanner.Project) { // Build and render tree root := buildTreeStructure(files) fmt.Fprintf(w, "%s%s%s\n", Bold, projectName, Reset) - printTreeNode(w, root, "", true, topLarge, 1, maxDepth) + printTreeNode(w, root, "", true, newHubMarks(project.Hubs), 1, maxDepth) + if !isDiffMode { + printHubLegend(w, project) + } // Print impact footer for diff mode if isDiffMode && len(project.Impact) > 0 { @@ -255,7 +244,7 @@ func Tree(w io.Writer, project scanner.Project) { // printTreeNode recursively prints tree nodes // currentDepth starts at 1 for the root level, maxDepth 0 means unlimited -func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topLarge map[string]bool, currentDepth, maxDepth int) { +func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, hubs hubMarks, currentDepth, maxDepth int) { // Check if we've exceeded depth limit if maxDepth > 0 && currentDepth > maxDepth { return @@ -334,8 +323,9 @@ func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topL connector = "└── " } - fmt.Fprintf(w, "%s%s%s %s/%s %s(%s)%s\n", - prefix, connector, BoldBlue, mergedName, Reset, Dim, strings.Join(statsParts, ", "), Reset) + star, count := hubMark(hubs.dirs[current.path]) + fmt.Fprintf(w, "%s%s%s %s%s/%s%s %s(%s)%s\n", + prefix, connector, BoldBlue, star, mergedName, Reset, count, Dim, strings.Join(statsParts, ", "), Reset) newPrefix := prefix + "│ " if isLastDir { @@ -373,7 +363,7 @@ func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topL fmt.Fprintf(w, "%s└── %s... %s%s\n", newPrefix, Dim, strings.Join(parts, ", "), Reset) } } else { - printTreeNode(w, current, newPrefix, isLastDir, topLarge, currentDepth+1, maxDepth) + printTreeNode(w, current, newPrefix, isLastDir, hubs, currentDepth+1, maxDepth) } } @@ -411,7 +401,7 @@ func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topL prefix = "✎ " prefixWidth = 3 color = Bold + Yellow - } else if topLarge[f.file.Path] { + } else if hubs.files[f.path] > 0 { prefix = "⭐️ " prefixWidth = 3 color = Bold + color @@ -433,6 +423,10 @@ func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topL } suffixWidth = len(suffix) } + if _, count := hubMark(hubs.files[f.path]); count != "" { + suffix += count + suffixWidth += len([]rune(count)) + } display := prefix + displayName + suffix colored := fmt.Sprintf("%s%s%s%s%s%s", color, prefix, displayName, Reset, Dim, suffix+Reset) @@ -482,3 +476,25 @@ func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, topL } } } + +func hubMark(importers int) (star, count string) { + if importers > 0 { + return "⭐️ ", fmt.Sprintf(" ←%d", importers) + } + return "", "" +} + +// printHubLegend says what the star means, or why there are no stars. It +// prints nothing when the caller computed no hub data. +func printHubLegend(w io.Writer, project scanner.Project) { + switch { + case project.Hubs == nil && project.HubNote != "": + fmt.Fprintf(w, "%sNo ⭐️ hub marks: %s%s\n", Dim, project.HubNote, Reset) + case project.Hubs != nil: + legend := fmt.Sprintf("⭐️ ←N = imported by N files, %d+ of them non-test (Go: package count, on its dir)", scanner.HubThreshold) + if project.HubNote != "" { + legend += "; " + project.HubNote + } + fmt.Fprintf(w, "%s%s%s\n", Dim, legend, Reset) + } +} diff --git a/render/tree_hubs_test.go b/render/tree_hubs_test.go new file mode 100644 index 00000000..503cb499 --- /dev/null +++ b/render/tree_hubs_test.go @@ -0,0 +1,72 @@ +package render + +import ( + "bytes" + "strings" + "testing" + + "codemap/scanner" +) + +func renderHubTree(t *testing.T, project scanner.Project) string { + t.Helper() + project.Name = "demo" + project.Root = t.TempDir() + var buf bytes.Buffer + Tree(&buf, project) + return stripTreeANSI(buf.String()) +} + +func hubTestFiles(paths ...string) []scanner.FileInfo { + files := make([]scanner.FileInfo, len(paths)) + for i, p := range paths { + files[i] = scanner.FileInfo{Path: p, Ext: p[strings.LastIndex(p, "."):], Size: 10} + } + return files +} + +func TestTreeStarsHubsWithImporterCounts(t *testing.T) { + out := renderHubTree(t, scanner.Project{ + Files: hubTestFiles("scanner/a.go", "scanner/b.go", "web/types.ts", "web/x.ts", "web/big_but_unused.ts"), + Hubs: []scanner.HubEntry{ + {Path: "scanner/", Files: []string{"scanner/a.go", "scanner/b.go"}, Importers: 73}, + {Path: "web/types.ts", Files: []string{"web/types.ts"}, Importers: 41}, + }, + }) + for _, want := range []string{ + "⭐️ scanner/ ←73", + "⭐️ types.ts ←41", + "⭐️ ←N = imported by N files, 3+ of them non-test (Go: package count, on its dir)", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + for _, unwanted := range []string{"⭐️ a.go", "⭐️ x.ts", "⭐️ big_but_unused.ts"} { + if strings.Contains(out, unwanted) { + t.Fatalf("unexpected %q in:\n%s", unwanted, out) + } + } +} + +func TestTreeHubLegendCarriesNote(t *testing.T) { + partial := renderHubTree(t, scanner.Project{Files: hubTestFiles("a.go"), Hubs: []scanner.HubEntry{}, HubNote: "graph coverage partial"}) + if !strings.Contains(partial, "of them non-test (Go: package count, on its dir); graph coverage partial") { + t.Fatalf("partial note missing:\n%s", partial) + } + skipped := renderHubTree(t, scanner.Project{Files: hubTestFiles("a.go"), HubNote: "6422 files is above the 5000-file graph limit"}) + if !strings.Contains(skipped, "No ⭐️ hub marks: 6422 files is above the 5000-file graph limit") || strings.Contains(skipped, "←N") { + t.Fatalf("skip note wrong:\n%s", skipped) + } +} + +func TestTreeWithoutHubDataHasNoStarsOrLegend(t *testing.T) { + plain := renderHubTree(t, scanner.Project{Files: hubTestFiles("a.go", "b.go")}) + if strings.Contains(plain, "⭐") { + t.Fatalf("stars without hub data:\n%s", plain) + } + diff := renderHubTree(t, scanner.Project{DiffRef: "main", Files: hubTestFiles("a.go"), HubNote: "x", Hubs: []scanner.HubEntry{}}) + if strings.Contains(diff, "⭐") { + t.Fatalf("legend in diff mode:\n%s", diff) + } +} diff --git a/render/tree_render_test.go b/render/tree_render_test.go index cc6a02b0..1992d6e0 100644 --- a/render/tree_render_test.go +++ b/render/tree_render_test.go @@ -68,7 +68,7 @@ func TestPrintTreeNodeFlattensDirsAndSummarizesHiddenContent(t *testing.T) { }} var buf bytes.Buffer - printTreeNode(&buf, root, "", true, nil, 1, 1) + printTreeNode(&buf, root, "", true, hubMarks{}, 1, 1) out := stripTreeANSI(buf.String()) if !strings.Contains(out, "pkg/feature/") { @@ -81,17 +81,17 @@ func TestPrintTreeNodeFlattensDirsAndSummarizesHiddenContent(t *testing.T) { func TestPrintTreeNodeMarksLargeAndChangedFiles(t *testing.T) { root := &treeNode{children: map[string]*treeNode{ - "big.go": {name: "big.go", isFile: true, file: &scanner.FileInfo{Path: "big.go", Ext: ".go", Size: 1000}}, + "big.go": {name: "big.go", path: "big.go", isFile: true, file: &scanner.FileInfo{Path: "big.go", Ext: ".go", Size: 1000}}, "edit.go": {name: "edit.go", isFile: true, file: &scanner.FileInfo{Path: "edit.go", Ext: ".go", Size: 100, Added: 3, Removed: 1}}, "new.go": {name: "new.go", isFile: true, file: &scanner.FileInfo{Path: "new.go", Ext: ".go", Size: 50, Added: 5, IsNew: true}}, }} var buf bytes.Buffer - printTreeNode(&buf, root, "", true, map[string]bool{"big.go": true}, 1, 0) + printTreeNode(&buf, root, "", true, hubMarks{files: map[string]int{"big.go": 7}}, 1, 0) out := stripTreeANSI(buf.String()) checks := []string{ - "⭐️ big.go", + "⭐️ big.go ←7", "✎ edit.go (+3 -1)", "(new) new.go (+5)", } diff --git a/render/tree_test.go b/render/tree_test.go index 51968d9f..0409c2a4 100644 --- a/render/tree_test.go +++ b/render/tree_test.go @@ -163,98 +163,6 @@ func TestGetDirStatsEmptyDir(t *testing.T) { } } -func TestGetTopLargeFiles(t *testing.T) { - files := []scanner.FileInfo{ - {Path: "small.go", Size: 100, Ext: ".go"}, - {Path: "medium.go", Size: 500, Ext: ".go"}, - {Path: "large.go", Size: 1000, Ext: ".go"}, - {Path: "huge.go", Size: 2000, Ext: ".go"}, - {Path: "giant.go", Size: 3000, Ext: ".go"}, - {Path: "tiny.go", Size: 50, Ext: ".go"}, - {Path: "massive.go", Size: 5000, Ext: ".go"}, - } - - top := getTopLargeFiles(files) - - // Should have 5 entries - if len(top) != 5 { - t.Errorf("Expected 5 top files, got %d", len(top)) - } - - // The 5 largest should be: massive, giant, huge, large, medium - expectedLarge := []string{"massive.go", "giant.go", "huge.go", "large.go", "medium.go"} - for _, path := range expectedLarge { - if !top[path] { - t.Errorf("Expected %s to be in top large files", path) - } - } - - // Smaller files should not be included - if top["small.go"] { - t.Error("small.go should not be in top large files") - } - if top["tiny.go"] { - t.Error("tiny.go should not be in top large files") - } -} - -func TestGetTopLargeFilesExcludesAssets(t *testing.T) { - files := []scanner.FileInfo{ - {Path: "code.go", Size: 100, Ext: ".go"}, - {Path: "huge_image.png", Size: 10000000, Ext: ".png"}, - {Path: "big_video.mp4", Size: 50000000, Ext: ".mp4"}, - } - - top := getTopLargeFiles(files) - - // Assets should be excluded - if top["huge_image.png"] { - t.Error("PNG should be excluded from top large files") - } - if top["big_video.mp4"] { - t.Error("MP4 should be excluded from top large files") - } - - // code.go should be the only "large" file - if !top["code.go"] { - t.Error("code.go should be in top large files") - } -} - -func TestGetTopLargeFilesFewerThan5(t *testing.T) { - files := []scanner.FileInfo{ - {Path: "one.go", Size: 100, Ext: ".go"}, - {Path: "two.go", Size: 200, Ext: ".go"}, - } - - top := getTopLargeFiles(files) - - if len(top) != 2 { - t.Errorf("Expected 2 files, got %d", len(top)) - } -} - -func TestGetTopLargeFilesBreaksSizeTiesByPath(t *testing.T) { - files := []scanner.FileInfo{ - {Path: "z.go", Size: 100, Ext: ".go"}, - {Path: "f.go", Size: 100, Ext: ".go"}, - {Path: "a.go", Size: 100, Ext: ".go"}, - {Path: "b.go", Size: 100, Ext: ".go"}, - {Path: "c.go", Size: 100, Ext: ".go"}, - {Path: "d.go", Size: 100, Ext: ".go"}, - {Path: "e.go", Size: 100, Ext: ".go"}, - } - top := getTopLargeFiles(files) - for _, path := range []string{"a.go", "b.go", "c.go", "d.go", "e.go"} { - if !top[path] { - t.Fatalf("top files = %v, want %s", top, path) - } - } - if top["f.go"] || top["z.go"] { - t.Fatalf("top files include a larger tie-break path: %v", top) - } -} - func TestTreeNodeStructure(t *testing.T) { // Test treeNode creation node := &treeNode{ diff --git a/scanner/types.go b/scanner/types.go index 8e9df316..ec1646f1 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -38,6 +38,10 @@ type Project struct { // ScanBound is set when the walk kept fewer files than matched, so Files // is a prefix of the repository, not the repository (#186). ScanBound *ScanBound `json:"scan_bound,omitempty"` + // Hubs and HubNote are set by the CLI tree path and never serialized. + // The tree prints a hub legend only when Hubs is non-nil or HubNote is set. + Hubs []HubEntry `json:"-"` + HubNote string `json:"-"` // why Hubs is empty or partial } // ScanBound records a walk that stopped keeping files at Limit. diff --git a/skills/builtin/codemap.md b/skills/builtin/codemap.md index ef03ac12..3e814f3d 100644 --- a/skills/builtin/codemap.md +++ b/skills/builtin/codemap.md @@ -124,7 +124,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ## Output Interpretation ### Tree View (`codemap .`) -- Stars (⭐) indicate top 5 largest source files +- `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so - Directories flattened when containing single subdirectory ### Dependency Flow (`codemap --deps`) diff --git a/tree_hubs.go b/tree_hubs.go new file mode 100644 index 00000000..ae9dc844 --- /dev/null +++ b/tree_hubs.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + + "codemap/analysis" + "codemap/config" + "codemap/limits" + "codemap/scanner" + "codemap/watch" +) + +// treeHubs ranks hubs for the tree's star marks: the daemon's validated graph +// when it was built with the same filters, else a fresh scan. The note says +// why hubs are missing or partial; it is empty for a complete graph. +func treeHubs(root string, fileCount int, filters scanner.Filters) ([]scanner.HubEntry, string) { + cfg := config.Load(root) + var fg *scanner.FileGraph + if slices.Equal(filters.Only, cfg.Only) && slices.Equal(filters.Exclude, cfg.Exclude) { + fg, _ = watch.ValidateCachedGraph(watch.ReadState(root), root, cfg) + } + if fg == nil { + if fileCount > limits.LargeRepoFileCount { + return nil, fmt.Sprintf("%d files is above the %d-file graph limit; run codemap --deps", fileCount, limits.LargeRepoFileCount) + } + var err error + if fg, err = scanner.BuildFileGraph(context.Background(), root, filters); err != nil { + return nil, "dependency graph unavailable: " + err.Error() + } + } + coverage := fg.Coverage.EffectiveStatus() + if coverage == analysis.CoverageUnavailable { + return nil, "dependency graph unavailable: " + strings.Join(fg.Coverage.Notes, "; ") + } + hubs := scanner.CollapseGoPackageHubs(fg.HubFiles(), fg.Importers) + sort.SliceStable(hubs, func(i, j int) bool { return hubs[i].Importers > hubs[j].Importers }) + if hubs == nil { + hubs = []scanner.HubEntry{} + } + if coverage == analysis.CoveragePartial { + return hubs, "graph coverage partial, counts are lower bounds (codemap --deps lists the gaps)" + } + return hubs, "" +} From 8659b52883296cef058b58a0cffc20e3fd5ab0b4 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 11 Nov 2026 14:00:00 -0500 Subject: [PATCH 2/3] feat(tree): compact tree with line counts, hub addresses and symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the hub stars and legend from #295. All changes apply to the plain `codemap .` tree; --json, --deps, --skyline and --diff are unchanged. - Fold same-directory tests into their source: `filegraph.go (+3 tests)` (Go x_test/x_*_test, JS/TS .test/.spec, Python test_x/x_test). - Count unmatched tests as `+N other tests`, except in a tests tree (test/, tests/, __tests__/, spec/ or no folded test), which lists its files. - Collapse 4+ siblings that differ only in digit-bearing tokens: `page-*.yml ×9`. - Collapse a wholly untracked directory (git ls-files --others --directory) to one line with its file count and size. - Print a single-file directory as `dir/FILE`, at any depth, so .claude/skills/ and plugins/codemap/skills/ render alike. - Give an overlong name its own row in sorted position instead of forcing a whole directory into one column. - Show physical line counts at 100+ lines (`hooks.go 2535L`; text files up to 2MB, binaries and assets skipped). - Read files for line counts and symbol lines only when Lstat says regular (symlinks, FIFOs and devices skipped), through a reader capped at 2MB+1. - Trailer: `Hubs (top 10 of N)` with full paths, importer counts and line counts, wrapped under 400 chars; `path:line Name` for up to 3 exported top-level functions of the top 5 file hubs, kept only when the reported source line names the symbol; `Next:` commands. - ast-grep function and type matches now record their line (FileAnalysis.SymbolLines, not serialized). - Session-start hook: hubs stated once (its own hub block is dropped when the tree prints the legend), and the tree sheds extras before depth via CODEMAP_TREE_LEAN (1: no symbols, top 3 hubs; 2: also no line counts) before fitSessionStart cuts depth. Renders are cached by depth and level. Measured (`codemap .`, warm, vs origin/main): - codemap (daemon cache warm): 0.04s -> 0.13s; docling (no daemon): 0.40s -> 1.97s, of which the hub graph scan is ~1.4s, line counts and symbols ~0.2s. - Output: codemap 307 -> 163 lines, 16.1KB -> 10.3KB; docling 492 -> 456 lines; CodeMachine-CLI 313 -> 324 lines. - Hook: werkzeug full depth at 8,040 chars (#295: 8,028, full depth); codemap full depth at 8,528 chars (#295: depth 1, 3,369). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01FWQw8DzwM4bQnL7mvk6Fsq --- .claude/skills/codemap/SKILL.md | 6 +- cmd/hooks.go | 41 +- cmd/hooks_tree_lean_test.go | 16 + docs/MCP.md | 2 +- main.go | 7 + mcp/main.go | 2 +- mcp/parity_contract_test.go | 3 + plugins/codemap/skills/codemap/SKILL.md | 6 +- render/tree.go | 283 +---------- render/tree_compact.go | 592 ++++++++++++++++++++++++ render/tree_compact_test.go | 236 ++++++++++ render/tree_render_test.go | 4 +- scanner/astgrep.go | 11 + scanner/types.go | 22 +- skills/builtin/codemap.md | 6 +- tree_detail.go | 145 ++++++ tree_detail_test.go | 72 +++ tree_hubs.go | 19 + tree_hubs_test.go | 35 ++ 19 files changed, 1210 insertions(+), 298 deletions(-) create mode 100644 cmd/hooks_tree_lean_test.go create mode 100644 render/tree_compact.go create mode 100644 render/tree_compact_test.go create mode 100644 tree_detail.go create mode 100644 tree_detail_test.go create mode 100644 tree_hubs_test.go diff --git a/.claude/skills/codemap/SKILL.md b/.claude/skills/codemap/SKILL.md index 53720a03..150ffd72 100644 --- a/.claude/skills/codemap/SKILL.md +++ b/.claude/skills/codemap/SKILL.md @@ -123,7 +123,11 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ### Tree View (`codemap .`) - `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so -- Directories flattened when containing single subdirectory +- `hooks.go 2535L` is the file's line count, shown at 100+ lines; read a range instead of the whole file +- `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) +- `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory +- Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` +- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/cmd/hooks.go b/cmd/hooks.go index aae2cf24..2cd1a4ef 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -503,9 +503,10 @@ func hookSessionStart(root string) error { depth = projCfg.Depth } exe, exeErr := os.Executable() - rendered := map[int]string{} - renderTree := func(depth int) string { - if out, ok := rendered[depth]; ok { + rendered := map[string]string{} + renderTreeAt := func(depth int, lean string) string { + key := strconv.Itoa(depth) + "/" + lean + if out, ok := rendered[key]; ok { return out } if exeErr != nil { @@ -520,26 +521,39 @@ func hookSessionStart(root string) error { } args = append(args, root) cmd := exec.Command(exe, args...) + if lean != "" { + cmd.Env = append(os.Environ(), TreeLeanEnv+"="+lean) + } var buf strings.Builder cmd.Stdout = &buf cmd.Stderr = os.Stderr cmd.Run() - rendered[depth] = buf.String() + "\n" - return rendered[depth] + rendered[key] = buf.String() + "\n" + return rendered[key] } // A tree with its own hub legend already marks every hub with its count; // the hook's hub list would state them twice. - if !treeStatesHubs(renderTree(depth)) { + if !treeStatesHubs(renderTreeAt(depth, "")) { trailer = hubBlock + trailer } + // Spend the budget on file names first: shed the tree's extras at full + // depth before fitSessionStart gives up depth. + maxBytes := sessionStartOutputCap() + treeBudget := min(structureBudget, maxBytes-len(header)-len(cutAtLine(trailer, maxBytes/2, "... (truncated to fit the hook output cap)\n"))) + lean := pickTreeLean(func(level string) string { return renderTreeAt(depth, level) }, treeBudget) + renderTree := func(depth int) string { return renderTreeAt(depth, lean) } - fmt.Print(fitSessionStart(header, trailer, depth, structureBudget, sessionStartOutputCap(), renderTree)) + fmt.Print(fitSessionStart(header, trailer, depth, structureBudget, maxBytes, renderTree)) return nil } -// Markers of the tree's hub legend (render.printHubLegend). +// TreeLeanEnv tells a tree render to shed extras: "1" drops hub symbol lines +// and the long hub list, "2" also drops line counts. +const TreeLeanEnv = "CODEMAP_TREE_LEAN" + +// Markers of the tree's own hub trailer (render/tree_compact.go). const ( treeHubLegend = "⭐️ ←N = " treeNoHubLegend = "No ⭐️ hub marks:" @@ -549,6 +563,17 @@ func treeStatesHubs(tree string) bool { return strings.Contains(tree, treeHubLegend) || strings.Contains(tree, treeNoHubLegend) } +// pickTreeLean returns the richest lean level whose render fits budget, or +// the leanest when none does (depth is cut after that). +func pickTreeLean(render func(lean string) string, budget int) string { + for _, level := range []string{"", "1"} { + if len(render(level)) <= budget { + return level + } + } + return "2" +} + // sessionStartOutputCap is the whole session-start budget; CODEMAP_HOOK_OUTPUT_CAP // overrides it for hosts with a different limit. func sessionStartOutputCap() int { diff --git a/cmd/hooks_tree_lean_test.go b/cmd/hooks_tree_lean_test.go new file mode 100644 index 00000000..d1161b52 --- /dev/null +++ b/cmd/hooks_tree_lean_test.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestPickTreeLeanShedsExtrasBeforeDepth(t *testing.T) { + sizes := map[string]int{"": 120, "1": 90, "2": 60} + render := func(lean string) string { return strings.Repeat("x", sizes[lean]) } + for budget, want := range map[int]string{200: "", 100: "1", 70: "2", 10: "2"} { + if got := pickTreeLean(render, budget); got != want { + t.Errorf("budget %d: lean %q, want %q", budget, got, want) + } + } +} diff --git a/docs/MCP.md b/docs/MCP.md index 8932d96e..d56b5b79 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -111,7 +111,7 @@ and managed MCP definitions created while it is active retain it automatically. | Tool | Description | |------|-------------| -| `get_structure` | Project tree view with file sizes and language detection | +| `get_structure` | Project tree view with file sizes and language detection, tests folded into their source, plus the hub list | | `get_dependencies` | Dependency flow with imports, functions, and hub files | | `get_diff` | Changed files with line counts and impact analysis | | `find_file` | Find files by name pattern | diff --git a/main.go b/main.go index 57c56c71..281e0c3f 100644 --- a/main.go +++ b/main.go @@ -624,6 +624,13 @@ func main() { } else { if diffInfo == nil { project.Hubs, project.HubNote = treeHubs(absRoot, len(files), filters) + project.UntrackedDirs = untrackedDirs(root) + if lean := os.Getenv(cmd.TreeLeanEnv); lean != "2" { + project.LineCounts = countLines(absRoot, files) + if lean == "" { + project.HubSymbols = hubSymbols(absRoot, project.Hubs) + } + } } render.Tree(os.Stdout, project) } diff --git a/mcp/main.go b/mcp/main.go index 4a1afee5..de6711cc 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -239,7 +239,7 @@ func NewServer(options RuntimeOptions) *mcp.Server { // Tool: get_structure - Get project tree view mcp.AddTool(server, &mcp.Tool{ Name: "get_structure", - Description: "Get the project structure as a tree view. Shows files organized by directory with language detection, and file sizes. Use this to understand how a codebase is organized.", + Description: "Get the project structure as a tree view. Shows files organized by directory with language detection and file sizes; tests are folded into their same-directory source file and numbered sibling runs collapse to one pattern line. Hub counts are in the HUB FILES list below the tree. Use this to understand how a codebase is organized.", OutputSchema: mustSchemaFor[StructureOutput](), }, handleGetStructure) diff --git a/mcp/parity_contract_test.go b/mcp/parity_contract_test.go index f3a6efef..e8edc398 100644 --- a/mcp/parity_contract_test.go +++ b/mcp/parity_contract_test.go @@ -21,6 +21,9 @@ func TestStructureDepthPrecedence(t *testing.T) { for path, body := range map[string]string{ "a/one/b/c.go": "package b\n", "a/two/d/e.go": "package d\n", + // A second file keeps a/two/ from being a one-line single-file chain, + // which the tree prints whatever the depth. + "a/two/d/f.go": "package d\n", } { full := filepath.Join(root, path) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { diff --git a/plugins/codemap/skills/codemap/SKILL.md b/plugins/codemap/skills/codemap/SKILL.md index 53720a03..150ffd72 100644 --- a/plugins/codemap/skills/codemap/SKILL.md +++ b/plugins/codemap/skills/codemap/SKILL.md @@ -123,7 +123,11 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ### Tree View (`codemap .`) - `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so -- Directories flattened when containing single subdirectory +- `hooks.go 2535L` is the file's line count, shown at 100+ lines; read a range instead of the whole file +- `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) +- `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory +- Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` +- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/render/tree.go b/render/tree.go index dccfd491..01705174 100644 --- a/render/tree.go +++ b/render/tree.go @@ -22,25 +22,6 @@ type treeNode struct { statsReady bool } -// hubMarks maps tree paths to importer counts: single-file hubs by file -// path, multi-file Go packages by directory path. -type hubMarks struct { - files map[string]int - dirs map[string]int -} - -func newHubMarks(hubs []scanner.HubEntry) hubMarks { - m := hubMarks{files: map[string]int{}, dirs: map[string]int{}} - for _, h := range hubs { - if h.IsPackage() { - m.dirs[strings.TrimSuffix(h.Path, "/")] = h.Importers - } else { - m.files[h.Path] = h.Importers - } - } - return m -} - // getDirStats recursively calculates file count and total size func getDirStats(node *treeNode) (int, int64) { if node.isFile { @@ -224,9 +205,10 @@ func Tree(w io.Writer, project scanner.Project) { // Build and render tree root := buildTreeStructure(files) fmt.Fprintf(w, "%s%s%s\n", Bold, projectName, Reset) - printTreeNode(w, root, "", true, newHubMarks(project.Hubs), 1, maxDepth) - if !isDiffMode { - printHubLegend(w, project) + marks := newTreeMarks(project) + printTreeNode(w, root, "", true, marks, 1, maxDepth) + if marks.compact { + printHubTrailer(w, project) } // Print impact footer for diff mode @@ -241,260 +223,3 @@ func Tree(w io.Writer, project scanner.Project) { } } } - -// printTreeNode recursively prints tree nodes -// currentDepth starts at 1 for the root level, maxDepth 0 means unlimited -func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, hubs hubMarks, currentDepth, maxDepth int) { - // Check if we've exceeded depth limit - if maxDepth > 0 && currentDepth > maxDepth { - return - } - - // Separate dirs and files - var dirs, fileNodes []*treeNode - for _, child := range node.children { - if child.isFile { - fileNodes = append(fileNodes, child) - } else { - dirs = append(dirs, child) - } - } - - // Sort - sort.Slice(dirs, func(i, j int) bool { return dirs[i].name < dirs[j].name }) - sort.Slice(fileNodes, func(i, j int) bool { return fileNodes[i].name < fileNodes[j].name }) - - // Print directories first - for i, dir := range dirs { - isLastDir := i == len(dirs)-1 && len(fileNodes) == 0 - - // Flatten single-child directories - mergedName := dir.name - current := dir - for len(current.children) == 1 { - var onlyChild *treeNode - for _, c := range current.children { - onlyChild = c - } - if onlyChild.isFile { - break - } - mergedName = mergedName + "/" + onlyChild.name - current = onlyChild - } - - // Get stats - fileCount, totalSize := getDirStats(current) - - // Check for homogeneous extensions - var commonExt string - immediateFiles := make([]*scanner.FileInfo, 0) - for _, c := range current.children { - if c.isFile { - immediateFiles = append(immediateFiles, c.file) - } - } - if len(immediateFiles) > 1 { - extSet := make(map[string]bool) - for _, f := range immediateFiles { - extSet[f.Ext] = true - } - if len(extSet) == 1 { - for ext := range extSet { - commonExt = ext - } - } - } - - // Format stats - var statsParts []string - if fileCount == 1 { - statsParts = append(statsParts, formatSize(totalSize)) - } else { - statsParts = append(statsParts, fmt.Sprintf("%d files", fileCount)) - statsParts = append(statsParts, formatSize(totalSize)) - } - if commonExt != "" { - statsParts = append(statsParts, fmt.Sprintf("all %s", commonExt)) - } - - connector := "├── " - if isLastDir { - connector = "└── " - } - - star, count := hubMark(hubs.dirs[current.path]) - fmt.Fprintf(w, "%s%s%s %s%s/%s%s %s(%s)%s\n", - prefix, connector, BoldBlue, star, mergedName, Reset, count, Dim, strings.Join(statsParts, ", "), Reset) - - newPrefix := prefix + "│ " - if isLastDir { - newPrefix = prefix + " " - } - - // Check if we're at max depth and there's more content below - if maxDepth > 0 && currentDepth >= maxDepth { - // Count hidden content - hiddenDirs := 0 - hiddenFiles := 0 - for _, c := range current.children { - if c.isFile { - hiddenFiles++ - } else { - hiddenDirs++ - } - } - if hiddenDirs > 0 || hiddenFiles > 0 { - var parts []string - if hiddenDirs > 0 { - if hiddenDirs == 1 { - parts = append(parts, "1 directory") - } else { - parts = append(parts, fmt.Sprintf("%d directories", hiddenDirs)) - } - } - if hiddenFiles > 0 { - if hiddenFiles == 1 { - parts = append(parts, "1 file") - } else { - parts = append(parts, fmt.Sprintf("%d files", hiddenFiles)) - } - } - fmt.Fprintf(w, "%s└── %s... %s%s\n", newPrefix, Dim, strings.Join(parts, ", "), Reset) - } - } else { - printTreeNode(w, current, newPrefix, isLastDir, hubs, currentDepth+1, maxDepth) - } - } - - // Print files as a grid (multi-column layout like Python) - if len(fileNodes) > 0 { - connector := "└── " - termWidth := GetTerminalWidth() - availableWidth := termWidth - len(prefix) - len(connector) - if availableWidth < 40 { - availableWidth = 40 - } - - // Build file entries with colors - type fileEntry struct { - display string - colored string - width int - } - var entries []fileEntry - for _, f := range fileNodes { - color := GetFileColor(f.file.Ext) - // Always the full file name. Stripping a shared extension made - // multi-file and single-file directories render differently, so - // a path could not be copied out of the tree (#154). - displayName := f.name - - // Prefix: diff status indicator OR star for large files - prefix := "" - prefixWidth := 0 - if f.file.IsNew { - prefix = "(new) " - prefixWidth = 6 - color = Bold + Green - } else if f.file.Added > 0 || f.file.Removed > 0 { - prefix = "✎ " - prefixWidth = 3 - color = Bold + Yellow - } else if hubs.files[f.path] > 0 { - prefix = "⭐️ " - prefixWidth = 3 - color = Bold + color - } - - // Suffix: diff stats - suffix := "" - suffixWidth := 0 - if f.file.IsNew && f.file.Added > 0 { - // New file: just show total lines - suffix = fmt.Sprintf(" (+%d)", f.file.Added) - suffixWidth = len(suffix) - } else if f.file.Added > 0 || f.file.Removed > 0 { - // Modified file: show +/- - if f.file.Removed > 0 { - suffix = fmt.Sprintf(" (+%d -%d)", f.file.Added, f.file.Removed) - } else { - suffix = fmt.Sprintf(" (+%d)", f.file.Added) - } - suffixWidth = len(suffix) - } - if _, count := hubMark(hubs.files[f.path]); count != "" { - suffix += count - suffixWidth += len([]rune(count)) - } - - display := prefix + displayName + suffix - colored := fmt.Sprintf("%s%s%s%s%s%s", color, prefix, displayName, Reset, Dim, suffix+Reset) - width := prefixWidth + len(displayName) + suffixWidth - entries = append(entries, fileEntry{display, colored, width}) - } - - // Calculate columns - find max width and fit columns - maxWidth := 0 - for _, e := range entries { - if e.width > maxWidth { - maxWidth = e.width - } - } - colWidth := maxWidth + 1 - numCols := availableWidth / colWidth - if numCols < 1 { - numCols = 1 - } - if numCols > len(entries) { - numCols = len(entries) - } - - // Calculate number of rows - numRows := (len(entries) + numCols - 1) / numCols - - // Print in column-major order (like Python) - for row := 0; row < numRows; row++ { - if row == 0 { - fmt.Fprintf(w, "%s%s", prefix, connector) - } else { - fmt.Fprintf(w, "%s ", prefix) - } - for col := 0; col < numCols; col++ { - idx := col*numRows + row - if idx < len(entries) { - e := entries[idx] - // Pad to column width - padding := colWidth - e.width - if padding < 0 { - padding = 0 - } - fmt.Fprintf(w, "%s%s", e.colored, strings.Repeat(" ", padding)) - } - } - fmt.Fprintln(w) - } - } -} - -func hubMark(importers int) (star, count string) { - if importers > 0 { - return "⭐️ ", fmt.Sprintf(" ←%d", importers) - } - return "", "" -} - -// printHubLegend says what the star means, or why there are no stars. It -// prints nothing when the caller computed no hub data. -func printHubLegend(w io.Writer, project scanner.Project) { - switch { - case project.Hubs == nil && project.HubNote != "": - fmt.Fprintf(w, "%sNo ⭐️ hub marks: %s%s\n", Dim, project.HubNote, Reset) - case project.Hubs != nil: - legend := fmt.Sprintf("⭐️ ←N = imported by N files, %d+ of them non-test (Go: package count, on its dir)", scanner.HubThreshold) - if project.HubNote != "" { - legend += "; " + project.HubNote - } - fmt.Fprintf(w, "%s%s%s\n", Dim, legend, Reset) - } -} diff --git a/render/tree_compact.go b/render/tree_compact.go new file mode 100644 index 00000000..712a51fe --- /dev/null +++ b/render/tree_compact.go @@ -0,0 +1,592 @@ +package render + +import ( + "fmt" + "io" + pathpkg "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "codemap/scanner" +) + +// treeMarks carries the plain-tree annotations. compact is false for a diff +// render, whose output stays as it was. +type treeMarks struct { + compact bool + hubFiles map[string]int + hubDirs map[string]int + untracked map[string]bool + lines map[string]int +} + +func newTreeMarks(project scanner.Project) *treeMarks { + m := &treeMarks{compact: project.DiffRef == "", hubFiles: map[string]int{}, hubDirs: map[string]int{}, untracked: project.UntrackedDirs, lines: project.LineCounts} + for _, h := range project.Hubs { + if h.IsPackage() { + m.hubDirs[strings.TrimSuffix(h.Path, "/")] = h.Importers + } else { + m.hubFiles[h.Path] = h.Importers + } + } + return m +} + +func onlyChild(node *treeNode) *treeNode { + for _, c := range node.children { + return c + } + return nil +} + +// isSingleFileChain reports whether dir holds exactly one file, possibly +// through a chain of single-subdirectory directories. +func isSingleFileChain(dir *treeNode) bool { + for !dir.isFile && len(dir.children) == 1 { + dir = onlyChild(dir) + } + return dir.isFile +} + +func countAndSize(count int, size int64) string { + if count == 1 { + return formatSize(size) + } + return fmt.Sprintf("%d files, %s", count, formatSize(size)) +} + +func plural(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} + +// loc is " NL" for a file with a line count, else "". +func (m *treeMarks) loc(path string) string { return locLabel(m.lines, path) } + +// minLOCShown keeps short files unlabelled: a line count matters when it +// tells a reader to read a range instead of the whole file. +const minLOCShown = 100 + +// MaxLineCountBytes bounds the files whose lines are counted. +const MaxLineCountBytes = 2 << 20 + +func locLabel(lines map[string]int, path string) string { + if n, ok := lines[path]; ok && n >= minLOCShown { + return fmt.Sprintf(" %dL", n) + } + return "" +} + +func hubMark(n int) (star, count string) { + if n > 0 { + return "⭐️ ", fmt.Sprintf(" ←%d", n) + } + return "", "" +} + +// printTreeNode recursively prints tree nodes +// currentDepth starts at 1 for the root level, maxDepth 0 means unlimited +func printTreeNode(w io.Writer, node *treeNode, prefix string, isLast bool, marks *treeMarks, currentDepth, maxDepth int) { + if maxDepth > 0 && currentDepth > maxDepth { + return + } + if marks == nil { + marks = &treeMarks{} + } + + var dirs, fileNodes []*treeNode + for _, child := range node.children { + if child.isFile { + fileNodes = append(fileNodes, child) + } else { + dirs = append(dirs, child) + } + } + sort.Slice(dirs, func(i, j int) bool { return dirs[i].name < dirs[j].name }) + sort.Slice(fileNodes, func(i, j int) bool { return fileNodes[i].name < fileNodes[j].name }) + + for i, dir := range dirs { + isLastDir := i == len(dirs)-1 && len(fileNodes) == 0 + connector := "├── " + newPrefix := prefix + "│ " + if isLastDir { + connector = "└── " + newPrefix = prefix + " " + } + + // Flatten single-child directories; an untracked one ends the chain. + mergedName := dir.name + current := dir + for len(current.children) == 1 && !marks.untracked[current.path] { + c := onlyChild(current) + if c.isFile { + break + } + mergedName = mergedName + "/" + c.name + current = c + } + fileCount, totalSize := getDirStats(current) + + if marks.compact && marks.untracked[current.path] { + fmt.Fprintf(w, "%s%s%s %s/%s %s(untracked, %s)%s\n", + prefix, connector, BoldBlue, mergedName, Reset, Dim, countAndSize(fileCount, totalSize), Reset) + continue + } + if marks.compact && len(current.children) == 1 { + f := onlyChild(current) + star, count := hubMark(marks.hubFiles[f.path]) + fmt.Fprintf(w, "%s%s%s %s%s/%s%s%s%s%s %s(%s)%s\n", + prefix, connector, BoldBlue, star, mergedName, Reset, GetFileColor(f.file.Ext), f.name, marks.loc(f.path)+count, Reset, Dim, formatSize(totalSize), Reset) + continue + } + + // Check for homogeneous extensions + var commonExt string + immediateFiles := make([]*scanner.FileInfo, 0) + for _, c := range current.children { + if c.isFile { + immediateFiles = append(immediateFiles, c.file) + } + } + if len(immediateFiles) > 1 { + extSet := make(map[string]bool) + for _, f := range immediateFiles { + extSet[f.Ext] = true + } + if len(extSet) == 1 { + for ext := range extSet { + commonExt = ext + } + } + } + + statsParts := []string{countAndSize(fileCount, totalSize)} + if commonExt != "" { + statsParts = append(statsParts, fmt.Sprintf("all %s", commonExt)) + } + star, count := hubMark(marks.hubDirs[current.path]) + fmt.Fprintf(w, "%s%s%s %s%s/%s%s %s(%s)%s\n", + prefix, connector, BoldBlue, star, mergedName, Reset, count, Dim, strings.Join(statsParts, ", "), Reset) + + if maxDepth > 0 && currentDepth >= maxDepth { + // One-line single-file dirs cost no depth, so .claude/skills/ and a + // deeper plugins/codemap/skills/ render the same shape. + allChains := marks.compact && len(current.children) > 0 + hiddenDirs, hiddenFiles := 0, 0 + for _, c := range current.children { + if c.isFile { + hiddenFiles++ + allChains = false + } else { + hiddenDirs++ + allChains = allChains && isSingleFileChain(c) + } + } + if allChains { + printTreeNode(w, current, newPrefix, isLastDir, marks, currentDepth+1, 0) + continue + } + var parts []string + if hiddenDirs > 0 { + parts = append(parts, plural(hiddenDirs, "directory", "directories")) + } + if hiddenFiles > 0 { + parts = append(parts, plural(hiddenFiles, "file", "files")) + } + if len(parts) > 0 { + fmt.Fprintf(w, "%s└── %s... %s%s\n", newPrefix, Dim, strings.Join(parts, ", "), Reset) + } + } else { + printTreeNode(w, current, newPrefix, isLastDir, marks, currentDepth+1, maxDepth) + } + } + + if len(fileNodes) > 0 { + printFileGrid(w, fileNodes, prefix, marks) + } +} + +type fileEntry struct { + colored string + width int +} + +// printFileGrid prints a directory's files in column-major order. +func printFileGrid(w io.Writer, fileNodes []*treeNode, prefix string, marks *treeMarks) { + connector := "└── " + availableWidth := GetTerminalWidth() - len(prefix) - len(connector) + if availableWidth < 40 { + availableWidth = 40 + } + + var items []gridItem + orphans := 0 + if marks.compact { + items, orphans = compactFiles(fileNodes, marks) + } else { + for _, f := range fileNodes { + items = append(items, gridItem{node: f}) + } + } + var entries []fileEntry + for _, it := range items { + entries = append(entries, it.entry(marks)) + } + if orphans > 0 { + label := "+" + plural(orphans, "other test", "other tests") + entries = append(entries, fileEntry{Dim + label + Reset, len(label)}) + } + + widths := make([]int, len(entries)) + for i, e := range entries { + widths[i] = e.width + } + colWidth := gridColumnWidth(widths, availableWidth, marks.compact) + numCols := max(1, min(availableWidth/colWidth, len(entries))) + row := 0 + writeRow := func(cells []fileEntry) { + lead := prefix + " " + if row == 0 { + lead = prefix + connector + } + fmt.Fprint(w, lead) + for _, e := range cells { + fmt.Fprintf(w, "%s%s", e.colored, strings.Repeat(" ", max(0, colWidth-e.width))) + } + fmt.Fprintln(w) + row++ + } + + if maxInt(widths) < colWidth { + // Column-major, as before. + numRows := (len(entries) + numCols - 1) / numCols + for r := 0; r < numRows; r++ { + var cells []fileEntry + for col := 0; col < numCols; col++ { + if idx := col*numRows + r; idx < len(entries) { + cells = append(cells, entries[idx]) + } + } + writeRow(cells) + } + return + } + // An entry too wide for a column takes a row of its own in place, so the + // grid reads in sorted order left to right. + var cells []fileEntry + for _, e := range entries { + if e.width >= colWidth { + if len(cells) > 0 { + writeRow(cells) + cells = nil + } + writeRow([]fileEntry{e}) + continue + } + if cells = append(cells, e); len(cells) == numCols { + writeRow(cells) + cells = nil + } + } + if len(cells) > 0 { + writeRow(cells) + } +} + +func maxInt(values []int) int { + m := 0 + for _, v := range values { + m = max(m, v) + } + return m +} + +// gridColumnWidth is the widest grid entry plus one; entries at least this +// wide go on their own lines. When the widest entry would force a single +// column, a compact tree moves up to a tenth of the entries (at least one) +// to their own lines, so one long name cannot flatten the whole directory. +func gridColumnWidth(widths []int, available int, allowOutliers bool) int { + sorted := append([]int(nil), widths...) + sort.Sort(sort.Reverse(sort.IntSlice(sorted))) + if allowOutliers && len(sorted) >= 5 && available/(sorted[0]+1) < 2 { + limit := max(1, len(sorted)/10) + for k := 1; k <= limit; k++ { + if available/(sorted[k]+1) >= 2 { + return sorted[k] + 1 + } + } + } + return sorted[0] + 1 +} + +// gridItem is one cell of a file grid: a file with its folded tests, or a +// run of siblings collapsed to their shared name pattern (run > 0). +type gridItem struct { + node *treeNode + tests int + run int + key string +} + +func (it gridItem) entry(marks *treeMarks) fileEntry { + if it.run > 0 { + label := fmt.Sprintf("%s ×%d", it.key, it.run) + return fileEntry{GetFileColor(it.node.file.Ext) + label + Reset, len([]rune(label))} + } + f := it.node + color := GetFileColor(f.file.Ext) + prefix, prefixWidth := "", 0 + switch { + case f.file.IsNew: + prefix, prefixWidth, color = "(new) ", 6, Bold+Green + case f.file.Added > 0 || f.file.Removed > 0: + prefix, prefixWidth, color = "✎ ", 3, Bold+Yellow + case marks.hubFiles[f.path] > 0: + prefix, prefixWidth, color = "⭐️ ", 3, Bold+color + } + suffix := "" + if f.file.IsNew && f.file.Added > 0 { + suffix = fmt.Sprintf(" (+%d)", f.file.Added) + } else if f.file.Added > 0 || f.file.Removed > 0 { + if f.file.Removed > 0 { + suffix = fmt.Sprintf(" (+%d -%d)", f.file.Added, f.file.Removed) + } else { + suffix = fmt.Sprintf(" (+%d)", f.file.Added) + } + } + _, count := hubMark(marks.hubFiles[f.path]) + suffix += marks.loc(f.path) + count + if it.tests > 0 { + suffix += " (+" + plural(it.tests, "test", "tests") + ")" + } + width := prefixWidth + len(f.name) + len([]rune(suffix)) + return fileEntry{fmt.Sprintf("%s%s%s%s%s%s", color, prefix, f.name, Reset, Dim, suffix+Reset), width} +} + +const minRun = 4 + +// compactFiles folds test files into their same-directory subject and +// collapses runs of minRun+ siblings that share a runKey. Tests with no +// subject in the directory are only counted, except in a tests tree. +func compactFiles(files []*treeNode, marks *treeMarks) (items []gridItem, orphans int) { + exists := make(map[string]bool, len(files)) + for _, f := range files { + exists[f.name] = true + } + tests := map[string]int{} + subjects := map[*treeNode]string{} + folded := 0 + for _, f := range files { + if subject, isTest := testSubject(f.name, exists); isTest { + subjects[f] = subject + if subject != "" { + tests[subject]++ + folded++ + } else { + orphans++ + } + } + } + // A tests tree, not a source dir with stray tests: list the unmatched + // tests by name. + listOrphans := folded == 0 || testDirNames[pathpkg.Base(pathpkg.Dir(files[0].path))] + var kept []*treeNode + for _, f := range files { + subject, isTest := subjects[f] + if !isTest || subject == "" && listOrphans { + kept = append(kept, f) + } + } + if listOrphans { + orphans = 0 + } + keys := map[*treeNode]string{} + members := map[string]int{} + for _, f := range kept { + if key, varied := runKey(f.name); varied && tests[f.name] == 0 && marks.hubFiles[f.path] == 0 { + keys[f] = key + members[key]++ + } + } + emitted := map[string]bool{} + for _, f := range kept { + if key, ok := keys[f]; ok && members[key] >= minRun { + if !emitted[key] { + emitted[key] = true + items = append(items, gridItem{node: f, run: members[key], key: key}) + } + continue + } + items = append(items, gridItem{node: f, tests: tests[f.name]}) + } + return items, orphans +} + +// testDirNames are directory names that conventionally hold only tests. +var testDirNames = map[string]bool{"test": true, "tests": true, "__tests__": true, "spec": true, "specs": true} + +var jsTestSubjectExts = []string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} + +// testSubject reports whether name is a test file by language convention and, +// when the file it tests is in the same directory, that file's name. +// Go: x_test.go, x_more_test.go -> x.go (longest existing prefix wins). +// JS/TS: x.test.ts, x.spec.tsx -> same extension, then any JS/TS one. +// Python: test_x.py, x_test.py -> x.py. +func testSubject(name string, exists map[string]bool) (string, bool) { + pick := func(candidate string) string { + if exists[candidate] { + return candidate + } + return "" + } + if stem, ok := strings.CutSuffix(name, "_test.go"); ok { + for stem != "" { + if exists[stem+".go"] { + return stem + ".go", true + } + i := strings.LastIndexByte(stem, '_') + if i <= 0 { + break + } + stem = stem[:i] + } + return "", true + } + if stem, ok := strings.CutSuffix(name, ".py"); ok { + if s, ok := strings.CutPrefix(stem, "test_"); ok && s != "" { + return pick(s + ".py"), true + } + if s, ok := strings.CutSuffix(stem, "_test"); ok && s != "" { + return pick(s + ".py"), true + } + return "", false + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + stem, ok := strings.CutSuffix(base, ".test") + if !ok { + stem, ok = strings.CutSuffix(base, ".spec") + } + if !ok || stem == "" { + return "", false + } + if exists[stem+ext] { + return stem + ext, true + } + for _, e := range jsTestSubjectExts { + if exists[stem+e] { + return stem + e, true + } + } + return "", true +} + +var ( + runToken = regexp.MustCompile(`[A-Za-z0-9]+`) + runWordNumber = regexp.MustCompile(`^([A-Za-z]{2,})[0-9]+$`) + runStars = regexp.MustCompile(`\*(?:[-_.:]\*)+`) +) + +// runKey replaces every digit-bearing alphanumeric token of name's stem +// (timestamps, hashes, numbers) with "*", keeping a leading word of 2+ +// letters (file12 -> file*). Names sharing a key differ only in those tokens. +func runKey(name string) (string, bool) { + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + varied := false + key := runToken.ReplaceAllStringFunc(stem, func(tok string) string { + if !strings.ContainsAny(tok, "0123456789") { + return tok + } + varied = true + if m := runWordNumber.FindStringSubmatch(tok); m != nil { + return m[1] + "*" + } + return "*" + }) + return runStars.ReplaceAllString(key, "*") + ext, varied +} + +// printHubTrailer prints the hub legend, the top three hubs and the next +// commands: at most three lines, all from this run's graph. +func printHubTrailer(w io.Writer, project scanner.Project) { + if project.Hubs == nil { + if project.HubNote != "" { + fmt.Fprintf(w, "%sNo ⭐️ hub marks: %s%s\n", Dim, project.HubNote, Reset) + } + return + } + legend := fmt.Sprintf("⭐️ ←N = imported by N files, %d+ of them non-test (Go: package count, on its dir)", scanner.HubThreshold) + if project.LineCounts != nil { + legend += fmt.Sprintf("; NL = lines, shown at %d+ (text files up to %dMB)", minLOCShown, MaxLineCountBytes>>20) + } + if project.HubNote != "" { + legend += "; " + project.HubNote + } + fmt.Fprintf(w, "%s%s%s\n", Dim, legend, Reset) + if len(project.Hubs) == 0 { + return + } + // The long hub list rides with the symbol lines; a lean render keeps three. + detail := project.HubSymbols != nil + limit := 3 + if detail { + limit = maxTrailerHubs + } + var top []string + for i, h := range project.Hubs { + if i == limit { + break + } + top = append(top, fmt.Sprintf("%s ←%d%s", h.Path, h.Importers, locLabel(project.LineCounts, h.Path))) + } + if !detail { + fmt.Fprintf(w, "Top hubs: %s\n", strings.Join(top, ", ")) + } else { + label := fmt.Sprintf("Hubs (%d): ", len(top)) + if len(project.Hubs) > len(top) { + label = fmt.Sprintf("Hubs (top %d of %d): ", len(top), len(project.Hubs)) + } + for _, line := range wrapList(label, top, maxTrailerLine) { + fmt.Fprintln(w, line) + } + for _, h := range project.Hubs { + if syms := project.HubSymbols[h.Path]; len(syms) > 0 { + parts := make([]string, len(syms)) + for i, s := range syms { + parts[i] = fmt.Sprintf("%s:%d %s", h.Path, s.Line, s.Name) + } + fmt.Fprintf(w, " %s\n", strings.Join(parts, " ")) + } + } + } + fmt.Fprintf(w, "Next: codemap --deps · codemap --importers %s\n", project.Hubs[0].Files[0]) +} + +const ( + maxTrailerHubs = 10 + maxTrailerLine = 400 +) + +// wrapList joins items after label with ", ", starting an indented line +// before any item that would take a line past maxWidth runes. +func wrapList(label string, items []string, maxWidth int) []string { + var lines []string + line := label + for i, item := range items { + sep := ", " + if i == 0 { + sep = "" + } + if i > 0 && len([]rune(line+sep+item))+1 > maxWidth { + lines = append(lines, line+",") + line, sep = " ", "" + } + line += sep + item + } + return append(lines, line) +} diff --git a/render/tree_compact_test.go b/render/tree_compact_test.go new file mode 100644 index 00000000..e93534a1 --- /dev/null +++ b/render/tree_compact_test.go @@ -0,0 +1,236 @@ +package render + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "codemap/scanner" +) + +func renderCompactTree(t *testing.T, project scanner.Project) string { + t.Helper() + project.Name = "demo" + project.Root = t.TempDir() + var buf bytes.Buffer + Tree(&buf, project) + return stripTreeANSI(buf.String()) +} + +func goFiles(paths ...string) []scanner.FileInfo { + files := make([]scanner.FileInfo, len(paths)) + for i, p := range paths { + files[i] = scanner.FileInfo{Path: p, Ext: p[strings.LastIndex(p, "."):], Size: 10} + } + return files +} + +func TestTestSubjectByLanguage(t *testing.T) { + exists := map[string]bool{"x.go": true, "app.ts": true, "util.tsx": true, "mod.py": true} + cases := []struct { + name, subject string + isTest bool + }{ + {"x_test.go", "x.go", true}, + {"x_more_test.go", "x.go", true}, + {"x_more_deep_test.go", "x.go", true}, + {"orphan_test.go", "", true}, + {"app.test.ts", "app.ts", true}, + {"app.spec.ts", "app.ts", true}, + {"util.test.ts", "util.tsx", true}, + {"test_mod.py", "mod.py", true}, + {"mod_test.py", "mod.py", true}, + {"test_none.py", "", true}, + {"x.go", "", false}, + {"helper.py", "", false}, + {"README.md", "", false}, + } + for _, c := range cases { + subject, isTest := testSubject(c.name, exists) + if subject != c.subject || isTest != c.isTest { + t.Errorf("testSubject(%q) = (%q, %v), want (%q, %v)", c.name, subject, isTest, c.subject, c.isTest) + } + } +} + +func TestTreeFoldsTestsIntoSourceAndKeepsDirCounts(t *testing.T) { + out := renderCompactTree(t, scanner.Project{Files: goFiles( + "pkg/filegraph.go", "pkg/filegraph_test.go", "pkg/filegraph_more_test.go", "pkg/filegraph_go_test.go", + "pkg/other.go", "pkg/main_test.go", "pkg/testmain_test.go", + )}) + for _, want := range []string{"pkg/ (7 files", "filegraph.go (+3 tests)", "+2 other tests"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "filegraph_test.go") || strings.Contains(out, "other.go (+") { + t.Fatalf("test file listed or misattributed:\n%s", out) + } +} + +func TestTreeStarsHubsWithCountsAndLegend(t *testing.T) { + out := renderCompactTree(t, scanner.Project{ + Files: goFiles("scanner/a.go", "scanner/b.go", "web/types.ts", "web/x.ts"), + Hubs: []scanner.HubEntry{ + {Path: "scanner/", Files: []string{"scanner/a.go", "scanner/b.go"}, Importers: 73}, + {Path: "web/types.ts", Files: []string{"web/types.ts"}, Importers: 41}, + }, + }) + for _, want := range []string{ + "⭐️ scanner/ ←73", + "⭐️ types.ts ←41", + "⭐️ ←N = imported by N files, 3+ of them non-test", + "Top hubs: scanner/ ←73, web/types.ts ←41", + "Next: codemap --deps · codemap --importers scanner/a.go", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "⭐️ a.go") { + t.Fatalf("package file starred on its own:\n%s", out) + } +} + +func TestTreeSaysWhyHubStarsAreMissing(t *testing.T) { + out := renderCompactTree(t, scanner.Project{Files: goFiles("a.go"), HubNote: "dependency graph unavailable: no ast-grep"}) + if !strings.Contains(out, "No ⭐️ hub marks: dependency graph unavailable: no ast-grep") || strings.Contains(out, "Top hubs") { + t.Fatalf("unexpected hub legend:\n%s", out) + } + if plain := renderCompactTree(t, scanner.Project{Files: goFiles("a.go")}); strings.Contains(plain, "⭐") { + t.Fatalf("stars without hub data:\n%s", plain) + } +} + +func TestRunKeyCollapsesOnlyVaryingSegments(t *testing.T) { + var files []string + for _, stamp := range []string{"2026-10-21T12-01-02-111Z", "2026-10-21T12-05-09-222Z", "2026-10-22T08-00-00-333Z", "2026-10-23T09-30-00-444Z"} { + files = append(files, "logs/page-"+stamp+".yml") + } + files = append(files, "logs/console-2026-10-21T12-01-02-111Z.yml", "logs/001_init.sql", "logs/002_users.sql", "logs/003_posts.sql", "logs/004_tags.sql") + out := renderCompactTree(t, scanner.Project{Files: goFiles(files...)}) + if !strings.Contains(out, "page-*.yml ×4") { + t.Fatalf("run not collapsed:\n%s", out) + } + for _, keep := range []string{"console-2026-10-21T12-01-02-111Z.yml", "001_init.sql", "004_tags.sql"} { + if !strings.Contains(out, keep) { + t.Fatalf("%q merged though its fixed part differs:\n%s", keep, out) + } + } + if a, _ := runKey("file12.go"); a != "file*.go" { + t.Fatalf("runKey(file12.go) = %q", a) + } +} + +func TestTreeCollapsesUntrackedDirectory(t *testing.T) { + out := renderCompactTree(t, scanner.Project{ + Files: goFiles(".scratch/a.yml", ".scratch/deep/b.yml", "main.go"), + UntrackedDirs: map[string]bool{".scratch": true}, + }) + if !strings.Contains(out, ".scratch/ (untracked, 2 files, 20.0B)") || strings.Contains(out, "a.yml") { + t.Fatalf("untracked dir not collapsed:\n%s", out) + } +} + +func TestTreeSingleFileDirsRenderOneLineAtAnyDepth(t *testing.T) { + out := renderCompactTree(t, scanner.Project{Depth: 3, Files: goFiles( + ".claude/skills/codemap/SKILL.md", ".claude/skills/explore/SKILL.md", + "plugins/codemap/skills/codemap/SKILL.md", "plugins/codemap/skills/explore/SKILL.md", "plugins/codemap/README.md", + )}) + if strings.Count(out, "codemap/SKILL.md (10.0B)") != 2 || strings.Contains(out, "directories") { + t.Fatalf("single-file dirs rendered inconsistently:\n%s", out) + } +} + +func TestTreeLongNameDoesNotForceSingleColumn(t *testing.T) { + paths := []string{"cmd/" + strings.Repeat("very_long_name_", 5) + ".go"} + for _, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { + paths = append(paths, "cmd/"+n+".go") + } + out := renderCompactTree(t, scanner.Project{Files: goFiles(paths...)}) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, " a.go") && strings.Count(line, ".go") >= 2 { + if !strings.Contains(out, paths[0][len("cmd/"):]) { + t.Fatalf("long name dropped:\n%s", out) + } + return + } + } + t.Fatalf("long name forced one column:\n%s", out) +} + +func TestDetailTreeShowsLinesFullHubPathsAndSymbols(t *testing.T) { + out := renderCompactTree(t, scanner.Project{ + Files: goFiles("cmd/hooks.go", "cmd/hooks_test.go", "cmd/x.go"), + Hubs: []scanner.HubEntry{{Path: "cmd/hooks.go", Files: []string{"cmd/hooks.go"}, Importers: 5}}, + LineCounts: map[string]int{"cmd/hooks.go": 2535, "cmd/hooks_test.go": 900, "cmd/x.go": 12}, + HubSymbols: map[string][]scanner.Symbol{"cmd/hooks.go": {{Name: "Run", Line: 41}}}, + }) + for _, want := range []string{ + "⭐️ hooks.go 2535L ←5 (+1 test)", "x.go ", "shown at 100+", + "Hubs (1): cmd/hooks.go ←5 2535L", " cmd/hooks.go:41 Run", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } +} + +func TestDetailTreeHidesShortLineCountsAndCapsHubList(t *testing.T) { + var hubs []scanner.HubEntry + var paths []string + for i := 0; i < 30; i++ { + p := fmt.Sprintf("src/some/deeply/nested/module_%02d/implementation.ts", i) + paths = append(paths, p) + hubs = append(hubs, scanner.HubEntry{Path: p, Files: []string{p}, Importers: 100 - i}) + } + out := renderCompactTree(t, scanner.Project{Files: goFiles(paths...), Hubs: hubs, LineCounts: map[string]int{paths[0]: 12}, HubSymbols: map[string][]scanner.Symbol{}}) + if strings.Contains(out, "12L") { + t.Fatalf("line count under the floor shown:\n%s", out) + } + trailer := out[strings.Index(out, "⭐️ ←N"):] + if !strings.Contains(trailer, "Hubs (top 10 of 30): ") || !strings.Contains(trailer, "module_09/") || strings.Contains(trailer, "module_10/") { + t.Fatalf("hub list not capped:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if n := len([]rune(line)); n > maxTrailerLine { + t.Fatalf("line of %d runes:\n%s", n, line) + } + } +} + +func TestTestsOnlyDirListsTestFiles(t *testing.T) { + out := renderCompactTree(t, scanner.Project{Files: goFiles("tests/test_alpha.py", "tests/test_beta.py", "tests/conftest.py", "tests/utils.py", "tests/test_utils.py")}) + if !strings.Contains(out, "test_alpha.py") || !strings.Contains(out, "utils.py (+1 test)") || strings.Contains(out, "other test") { + t.Fatalf("tests-only dir collapsed:\n%s", out) + } +} + +func TestOutlierKeepsSortedOrder(t *testing.T) { + long := strings.Repeat("m", 75) + ".go" + paths := []string{"cmd/" + long} + for _, n := range []string{"a", "b", "c", "d", "x", "y", "z", "zz"} { + paths = append(paths, "cmd/"+n+".go") + } + out := renderCompactTree(t, scanner.Project{Files: goFiles(paths...)}) + ia, il, iz := strings.Index(out, " a.go"), strings.Index(out, long), strings.Index(out, " x.go") + if ia < 0 || il < ia || iz < il { + t.Fatalf("order not kept (a=%d long=%d x=%d):\n%s", ia, il, iz, out) + } + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, " a.go") && strings.Count(line, ".go") >= 2 { + return + } + } + t.Fatalf("outlier forced one column:\n%s", out) +} + +func TestDiffTreeKeepsTestsAndStars(t *testing.T) { + out := renderCompactTree(t, scanner.Project{DiffRef: "main", Files: []scanner.FileInfo{ + {Path: "a.go", Ext: ".go", Size: 1, Added: 1}, {Path: "a_test.go", Ext: ".go", Size: 1, Added: 1}, + }}) + if !strings.Contains(out, "a_test.go") || strings.Contains(out, "+1 test") || strings.Contains(out, "Top hubs") { + t.Fatalf("diff render changed:\n%s", out) + } +} diff --git a/render/tree_render_test.go b/render/tree_render_test.go index 1992d6e0..ccc3466e 100644 --- a/render/tree_render_test.go +++ b/render/tree_render_test.go @@ -68,7 +68,7 @@ func TestPrintTreeNodeFlattensDirsAndSummarizesHiddenContent(t *testing.T) { }} var buf bytes.Buffer - printTreeNode(&buf, root, "", true, hubMarks{}, 1, 1) + printTreeNode(&buf, root, "", true, nil, 1, 1) out := stripTreeANSI(buf.String()) if !strings.Contains(out, "pkg/feature/") { @@ -87,7 +87,7 @@ func TestPrintTreeNodeMarksLargeAndChangedFiles(t *testing.T) { }} var buf bytes.Buffer - printTreeNode(&buf, root, "", true, hubMarks{files: map[string]int{"big.go": 7}}, 1, 0) + printTreeNode(&buf, root, "", true, &treeMarks{hubFiles: map[string]int{"big.go": 7}}, 1, 0) out := stripTreeANSI(buf.String()) checks := []string{ diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 38e1c0a4..02f23d99 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -798,12 +798,14 @@ func analysesFromMatches(root string, matches []ScanMatch) []FileAnalysis { } else if strings.HasSuffix(m.RuleID, "-types") { if name := extractTypeName(m.Text); name != "" { fileMap[relPath].Types = append(fileMap[relPath].Types, name) + fileMap[relPath].recordSymbolLine(name, m.Range.Start.Line) } } else if strings.HasSuffix(m.RuleID, "-functions") { // Extract function name from text name := extractFunctionName(m.Text, fileMap[relPath].Language) if name != "" { fileMap[relPath].Functions = append(fileMap[relPath].Functions, name) + fileMap[relPath].recordSymbolLine(name, m.Range.Start.Line) } } } @@ -821,6 +823,15 @@ func analysesFromMatches(root string, matches []ScanMatch) []FileAnalysis { return results } +func (a *FileAnalysis) recordSymbolLine(name string, line int) { + if a.SymbolLines == nil { + a.SymbolLines = map[string]int{} + } + if prev, ok := a.SymbolLines[name]; !ok || line < prev { + a.SymbolLines[name] = line + } +} + func dedupeImportReferences(refs []ImportReference) []ImportReference { seen := make(map[ImportReference]bool) result := make([]ImportReference, 0, len(refs)) diff --git a/scanner/types.go b/scanner/types.go index ec1646f1..44b1b0e0 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -38,10 +38,21 @@ type Project struct { // ScanBound is set when the walk kept fewer files than matched, so Files // is a prefix of the repository, not the repository (#186). ScanBound *ScanBound `json:"scan_bound,omitempty"` - // Hubs and HubNote are set by the CLI tree path and never serialized. - // The tree prints a hub legend only when Hubs is non-nil or HubNote is set. - Hubs []HubEntry `json:"-"` - HubNote string `json:"-"` // why Hubs is empty or partial + // Tree-only annotations set by the CLI tree path; never serialized. The + // tree prints a hub legend only when Hubs is non-nil or HubNote is set. + Hubs []HubEntry `json:"-"` + HubNote string `json:"-"` // why Hubs is empty or partial + UntrackedDirs map[string]bool `json:"-"` // slash paths of wholly untracked dirs + // Physical line counts per file, and up to three exported symbols for + // each of the top hub files. + LineCounts map[string]int `json:"-"` + HubSymbols map[string][]Symbol `json:"-"` +} + +// Symbol is a declaration at a 1-based line of a file. +type Symbol struct { + Name string + Line int } // ScanBound records a walk that stopped keeping files at Limit. @@ -75,6 +86,9 @@ type FileAnalysis struct { Types []string `json:"types,omitempty"` Imports []string `json:"imports"` References []ImportReference `json:"-"` + // SymbolLines maps each function and type name to the 0-based line of its + // first ast-grep match, as ast-grep reports it. + SymbolLines map[string]int `json:"-"` } // ImportReference preserves language-specific import semantics for graph resolution. diff --git a/skills/builtin/codemap.md b/skills/builtin/codemap.md index 3e814f3d..d2e71a45 100644 --- a/skills/builtin/codemap.md +++ b/skills/builtin/codemap.md @@ -125,7 +125,11 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida ### Tree View (`codemap .`) - `⭐️ name ←N` marks a hub: N files import it (a Go package is marked on its directory); the legend under the tree says so -- Directories flattened when containing single subdirectory +- `hooks.go 2535L` is the file's line count, shown at 100+ lines; read a range instead of the whole file +- `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) +- `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory +- Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` +- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/tree_detail.go b/tree_detail.go new file mode 100644 index 00000000..dbe944de --- /dev/null +++ b/tree_detail.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "codemap/render" + "codemap/scanner" +) + +const ( + maxSymbolFiles = 5 + maxSymbolsPerFile = 3 +) + +// countLines returns physical line counts for files that are not assets, +// not binary (NUL in the first 8000 bytes) and at most render.MaxLineCountBytes. +func countLines(root string, files []scanner.FileInfo) map[string]int { + counts := make(map[string]int, len(files)) + for _, f := range files { + if render.IsAssetExtension(strings.ToLower(f.Ext)) { + continue + } + data, ok := readRegularFile(filepath.Join(root, f.Path)) + if !ok || bytes.IndexByte(data[:min(len(data), 8000)], 0) >= 0 { + continue + } + n := bytes.Count(data, []byte{'\n'}) + if len(data) > 0 && data[len(data)-1] != '\n' { + n++ + } + counts[filepath.ToSlash(f.Path)] = n + } + return counts +} + +// readRegularFile reads path only when it is a regular file (not a symlink, +// FIFO or device) of at most render.MaxLineCountBytes. The scan's size is not +// trusted: the read itself is capped. +func readRegularFile(path string) ([]byte, bool) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return nil, false + } + f, err := os.Open(path) + if err != nil { + return nil, false + } + defer f.Close() + if opened, err := f.Stat(); err != nil || !os.SameFile(info, opened) { + return nil, false + } + data, err := io.ReadAll(io.LimitReader(f, render.MaxLineCountBytes+1)) + if err != nil || len(data) > render.MaxLineCountBytes { + return nil, false + } + return data, true +} + +// hubSymbols lists up to maxSymbolsPerFile exported top-level symbols for +// the first maxSymbolFiles single-file hubs. A multi-file Go package is +// skipped: its importers name the package, not one file. +func hubSymbols(root string, hubs []scanner.HubEntry) map[string][]scanner.Symbol { + sg, err := scanner.NewAstGrepScanner() + if err != nil || !sg.Available() { + return nil + } + defer sg.Close() + out := map[string][]scanner.Symbol{} + picked := 0 + for _, h := range hubs { + if h.IsPackage() { + continue + } + if picked == maxSymbolFiles { + break + } + picked++ + if syms := fileSymbols(sg, root, h.Path); len(syms) > 0 { + out[h.Path] = syms + } + } + return out +} + +// fileSymbols keeps a symbol only when the source line ast-grep reported +// names it, so a wrong line number drops the symbol instead of printing it. +func fileSymbols(sg *scanner.AstGrepScanner, root, rel string) []scanner.Symbol { + outcome, err := sg.ScanDirectory(context.Background(), filepath.Join(root, filepath.Dir(rel))) + if err != nil { + return nil + } + var analysis *scanner.FileAnalysis + for i := range outcome.Analyses { + if filepath.ToSlash(outcome.Analyses[i].Path) == pathBase(rel) { + analysis = &outcome.Analyses[i] + } + } + src, ok := readRegularFile(filepath.Join(root, rel)) + if analysis == nil || !ok { + return nil + } + lines := strings.Split(string(src), "\n") + var syms []scanner.Symbol + for name, line := range analysis.SymbolLines { + if line < 0 || line >= len(lines) { + continue + } + text := lines[line] + if !regexp.MustCompile(`\b`+regexp.QuoteMeta(name)+`\b`).MatchString(text) || !exportedTopLevel(analysis.Language, name, text) { + continue + } + syms = append(syms, scanner.Symbol{Name: name, Line: line + 1}) + } + sort.Slice(syms, func(i, j int) bool { return syms[i].Line < syms[j].Line }) + return syms[:min(len(syms), maxSymbolsPerFile)] +} + +func pathBase(rel string) string { return filepath.Base(filepath.FromSlash(rel)) } + +// exportedTopLevel: the declaration starts its line (no indentation) and is +// visible outside the file by the language's rule. +func exportedTopLevel(language, name, text string) bool { + if text == "" || text[0] == ' ' || text[0] == '\t' { + return false + } + switch language { + case "go": + r, _ := utf8.DecodeRuneInString(name) + return unicode.IsUpper(r) && !strings.HasPrefix(text, "func (") + case "python": + return !strings.HasPrefix(name, "_") + case "typescript", "javascript": + return strings.HasPrefix(text, "export ") + } + return true +} diff --git a/tree_detail_test.go b/tree_detail_test.go new file mode 100644 index 00000000..e2df80cf --- /dev/null +++ b/tree_detail_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "codemap/scanner" +) + +func TestCountLinesPhysicalLinesSkippingBinaryAndAssets(t *testing.T) { + root := t.TempDir() + write := func(name, body string) scanner.FileInfo { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return scanner.FileInfo{Path: name, Ext: filepath.Ext(name), Size: int64(len(body))} + } + files := []scanner.FileInfo{ + write("a.go", "one\ntwo\n"), + write("b.go", "one\ntwo"), + write("empty.go", ""), + write("blob.dat", "x\x00y\n"), + write("pic.png", "not\nreally\n"), + } + got := countLines(root, files) + want := map[string]int{"a.go": 2, "b.go": 2, "empty.go": 0} + if len(got) != len(want) { + t.Fatalf("countLines = %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("countLines = %v, want %v", got, want) + } + } +} + +func TestCountLinesSkipsSymlinkOutsideRoot(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + target := filepath.Join(outside, "secret.go") + if err := os.WriteFile(target, []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "link.go")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + got := countLines(root, []scanner.FileInfo{{Path: "link.go", Ext: ".go", Size: 4}}) + if _, counted := got["link.go"]; counted { + t.Fatalf("symlink counted: %v", got) + } +} + +func TestExportedTopLevel(t *testing.T) { + cases := []struct { + lang, name, text string + want bool + }{ + {"go", "Load", "func Load(root string) Config {", true}, + {"go", "load", "func load() {", false}, + {"go", "String", "func (c Config) String() string {", false}, + {"python", "run", "def run():", true}, + {"python", "_private", "def _private():", false}, + {"python", "method", " def method(self):", false}, + {"typescript", "debug", "export function debug() {", true}, + {"typescript", "helper", "function helper() {", false}, + } + for _, c := range cases { + if got := exportedTopLevel(c.lang, c.name, c.text); got != c.want { + t.Errorf("exportedTopLevel(%q, %q, %q) = %v, want %v", c.lang, c.name, c.text, got, c.want) + } + } +} diff --git a/tree_hubs.go b/tree_hubs.go index ae9dc844..b45a8c42 100644 --- a/tree_hubs.go +++ b/tree_hubs.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "os/exec" "slices" "sort" "strings" @@ -46,3 +47,21 @@ func treeHubs(root string, fileCount int, filters scanner.Filters) ([]scanner.Hu } return hubs, "" } + +// untrackedDirs returns the directories under root that git reports as +// wholly untracked, as slash paths relative to root. Not a git repo: nil. +func untrackedDirs(root string) map[string]bool { + cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard", "--directory", "-z") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return nil + } + dirs := map[string]bool{} + for _, entry := range strings.Split(string(out), "\x00") { + if dir, ok := strings.CutSuffix(entry, "/"); ok && dir != "" && dir != "." { + dirs[dir] = true + } + } + return dirs +} diff --git a/tree_hubs_test.go b/tree_hubs_test.go new file mode 100644 index 00000000..433be8b7 --- /dev/null +++ b/tree_hubs_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestUntrackedDirsListsOnlyWhollyUntrackedDirs(t *testing.T) { + root := t.TempDir() + for _, p := range []string{"tracked/a.go", "tracked/new/b.go", "mixed/c.go", "mixed/d.go", "scratch/deep/e.yml"} { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{{"init", "-q"}, {"add", "tracked/a.go", "mixed/c.go"}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + got := untrackedDirs(root) + if !got["scratch"] || !got["tracked/new"] || got["mixed"] || got["tracked"] || len(got) != 2 { + t.Fatalf("untrackedDirs = %v, want scratch and tracked/new", got) + } + if untrackedDirs(t.TempDir()) != nil { + t.Fatal("non-repo should give nil") + } +} From 5251c984a8f49c5e48e0b8da94a8b6729aea22fe Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 11 Nov 2026 16:27:42 -0500 Subject: [PATCH 3/3] fix(tree): hub list one per line, drop symbol lines First-three-exported-in-file-order symbols were arbitrary and Go package hubs got none, so the symbol rows and the ast-grep line recording behind them are gone. The hub list prints one hub per line, right-aligned count first, so names align and no 400-char wrapping is needed; hook lean level 1 now cuts it to three rows. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01FWQw8DzwM4bQnL7mvk6Fsq --- .claude/skills/codemap/SKILL.md | 2 +- cmd/hooks.go | 4 +- main.go | 9 +-- plugins/codemap/skills/codemap/SKILL.md | 2 +- render/tree_compact.go | 70 +++++-------------- render/tree_compact_test.go | 52 +++++++-------- scanner/astgrep.go | 11 --- scanner/types.go | 17 ++--- skills/builtin/codemap.md | 2 +- tree_detail.go | 89 ------------------------- tree_detail_test.go | 21 ------ 11 files changed, 56 insertions(+), 223 deletions(-) diff --git a/.claude/skills/codemap/SKILL.md b/.claude/skills/codemap/SKILL.md index 150ffd72..054a99e7 100644 --- a/.claude/skills/codemap/SKILL.md +++ b/.claude/skills/codemap/SKILL.md @@ -127,7 +127,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida - `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) - `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory - Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` -- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands +- After the tree: the legend, then `Hubs (top 10 of N, by importers):` with one hub per line (`←73 scanner/`, full path, line count when 100+), then the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/cmd/hooks.go b/cmd/hooks.go index 2cd1a4ef..3ff33b83 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -549,8 +549,8 @@ func hookSessionStart(root string) error { return nil } -// TreeLeanEnv tells a tree render to shed extras: "1" drops hub symbol lines -// and the long hub list, "2" also drops line counts. +// TreeLeanEnv tells a tree render to shed extras: "1" cuts the hub list to +// three, "2" also drops line counts. const TreeLeanEnv = "CODEMAP_TREE_LEAN" // Markers of the tree's own hub trailer (render/tree_compact.go). diff --git a/main.go b/main.go index 281e0c3f..4536917d 100644 --- a/main.go +++ b/main.go @@ -625,11 +625,12 @@ func main() { if diffInfo == nil { project.Hubs, project.HubNote = treeHubs(absRoot, len(files), filters) project.UntrackedDirs = untrackedDirs(root) - if lean := os.Getenv(cmd.TreeLeanEnv); lean != "2" { + lean := os.Getenv(cmd.TreeLeanEnv) + if lean != "2" { project.LineCounts = countLines(absRoot, files) - if lean == "" { - project.HubSymbols = hubSymbols(absRoot, project.Hubs) - } + } + if lean != "" { + project.HubListMax = 3 } } render.Tree(os.Stdout, project) diff --git a/plugins/codemap/skills/codemap/SKILL.md b/plugins/codemap/skills/codemap/SKILL.md index 150ffd72..054a99e7 100644 --- a/plugins/codemap/skills/codemap/SKILL.md +++ b/plugins/codemap/skills/codemap/SKILL.md @@ -127,7 +127,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida - `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) - `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory - Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` -- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands +- After the tree: the legend, then `Hubs (top 10 of N, by importers):` with one hub per line (`←73 scanner/`, full path, line count when 100+), then the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/render/tree_compact.go b/render/tree_compact.go index 712a51fe..ea5af5c3 100644 --- a/render/tree_compact.go +++ b/render/tree_compact.go @@ -511,8 +511,8 @@ func runKey(name string) (string, bool) { return runStars.ReplaceAllString(key, "*") + ext, varied } -// printHubTrailer prints the hub legend, the top three hubs and the next -// commands: at most three lines, all from this run's graph. +// printHubTrailer prints the hub legend, the hubs one per line with their +// importer counts, and the next commands, all from this run's graph. func printHubTrailer(w io.Writer, project scanner.Project) { if project.Hubs == nil { if project.HubNote != "" { @@ -531,62 +531,24 @@ func printHubTrailer(w io.Writer, project scanner.Project) { if len(project.Hubs) == 0 { return } - // The long hub list rides with the symbol lines; a lean render keeps three. - detail := project.HubSymbols != nil - limit := 3 - if detail { + limit := project.HubListMax + if limit <= 0 { limit = maxTrailerHubs } - var top []string - for i, h := range project.Hubs { - if i == limit { - break - } - top = append(top, fmt.Sprintf("%s ←%d%s", h.Path, h.Importers, locLabel(project.LineCounts, h.Path))) - } - if !detail { - fmt.Fprintf(w, "Top hubs: %s\n", strings.Join(top, ", ")) + shown := project.Hubs[:min(limit, len(project.Hubs))] + if len(shown) < len(project.Hubs) { + fmt.Fprintf(w, "Hubs (top %d of %d, by importers):\n", len(shown), len(project.Hubs)) } else { - label := fmt.Sprintf("Hubs (%d): ", len(top)) - if len(project.Hubs) > len(top) { - label = fmt.Sprintf("Hubs (top %d of %d): ", len(top), len(project.Hubs)) - } - for _, line := range wrapList(label, top, maxTrailerLine) { - fmt.Fprintln(w, line) - } - for _, h := range project.Hubs { - if syms := project.HubSymbols[h.Path]; len(syms) > 0 { - parts := make([]string, len(syms)) - for i, s := range syms { - parts[i] = fmt.Sprintf("%s:%d %s", h.Path, s.Line, s.Name) - } - fmt.Fprintf(w, " %s\n", strings.Join(parts, " ")) - } - } + fmt.Fprintf(w, "Hubs (%d, by importers):\n", len(shown)) + } + width := 0 + for _, h := range shown { + width = max(width, len(fmt.Sprint(h.Importers))) + } + for _, h := range shown { + fmt.Fprintf(w, " ←%*d %s%s\n", width, h.Importers, h.Path, strings.Replace(locLabel(project.LineCounts, h.Path), " ", " ", 1)) } fmt.Fprintf(w, "Next: codemap --deps · codemap --importers %s\n", project.Hubs[0].Files[0]) } -const ( - maxTrailerHubs = 10 - maxTrailerLine = 400 -) - -// wrapList joins items after label with ", ", starting an indented line -// before any item that would take a line past maxWidth runes. -func wrapList(label string, items []string, maxWidth int) []string { - var lines []string - line := label - for i, item := range items { - sep := ", " - if i == 0 { - sep = "" - } - if i > 0 && len([]rune(line+sep+item))+1 > maxWidth { - lines = append(lines, line+",") - line, sep = " ", "" - } - line += sep + item - } - return append(lines, line) -} +const maxTrailerHubs = 10 diff --git a/render/tree_compact_test.go b/render/tree_compact_test.go index e93534a1..68de7c87 100644 --- a/render/tree_compact_test.go +++ b/render/tree_compact_test.go @@ -81,7 +81,7 @@ func TestTreeStarsHubsWithCountsAndLegend(t *testing.T) { "⭐️ scanner/ ←73", "⭐️ types.ts ←41", "⭐️ ←N = imported by N files, 3+ of them non-test", - "Top hubs: scanner/ ←73, web/types.ts ←41", + "Hubs (2, by importers):\n ←73 scanner/\n ←41 web/types.ts\n", "Next: codemap --deps · codemap --importers scanner/a.go", } { if !strings.Contains(out, want) { @@ -95,7 +95,7 @@ func TestTreeStarsHubsWithCountsAndLegend(t *testing.T) { func TestTreeSaysWhyHubStarsAreMissing(t *testing.T) { out := renderCompactTree(t, scanner.Project{Files: goFiles("a.go"), HubNote: "dependency graph unavailable: no ast-grep"}) - if !strings.Contains(out, "No ⭐️ hub marks: dependency graph unavailable: no ast-grep") || strings.Contains(out, "Top hubs") { + if !strings.Contains(out, "No ⭐️ hub marks: dependency graph unavailable: no ast-grep") || strings.Contains(out, "Hubs (") { t.Fatalf("unexpected hub legend:\n%s", out) } if plain := renderCompactTree(t, scanner.Project{Files: goFiles("a.go")}); strings.Contains(plain, "⭐") { @@ -160,43 +160,43 @@ func TestTreeLongNameDoesNotForceSingleColumn(t *testing.T) { t.Fatalf("long name forced one column:\n%s", out) } -func TestDetailTreeShowsLinesFullHubPathsAndSymbols(t *testing.T) { +func TestDetailTreeShowsLinesAndHubRows(t *testing.T) { out := renderCompactTree(t, scanner.Project{ - Files: goFiles("cmd/hooks.go", "cmd/hooks_test.go", "cmd/x.go"), - Hubs: []scanner.HubEntry{{Path: "cmd/hooks.go", Files: []string{"cmd/hooks.go"}, Importers: 5}}, + Files: goFiles("cmd/hooks.go", "cmd/hooks_test.go", "cmd/x.go", "scanner/a.go", "scanner/b.go"), + Hubs: []scanner.HubEntry{ + {Path: "scanner/", Files: []string{"scanner/a.go", "scanner/b.go"}, Importers: 73}, + {Path: "cmd/hooks.go", Files: []string{"cmd/hooks.go"}, Importers: 5}, + }, LineCounts: map[string]int{"cmd/hooks.go": 2535, "cmd/hooks_test.go": 900, "cmd/x.go": 12}, - HubSymbols: map[string][]scanner.Symbol{"cmd/hooks.go": {{Name: "Run", Line: 41}}}, }) - for _, want := range []string{ - "⭐️ hooks.go 2535L ←5 (+1 test)", "x.go ", "shown at 100+", - "Hubs (1): cmd/hooks.go ←5 2535L", " cmd/hooks.go:41 Run", - } { - if !strings.Contains(out, want) { - t.Fatalf("missing %q in:\n%s", want, out) + want := "Hubs (2, by importers):\n ←73 scanner/\n ← 5 cmd/hooks.go 2535L\nNext: codemap --deps · codemap --importers scanner/a.go\n" + for _, w := range []string{"⭐️ hooks.go 2535L ←5 (+1 test)", "shown at 100+", want} { + if !strings.Contains(out, w) { + t.Fatalf("missing %q in:\n%s", w, out) } } + if strings.Contains(out, "x.go 12L") { + t.Fatalf("line count under the floor shown:\n%s", out) + } } -func TestDetailTreeHidesShortLineCountsAndCapsHubList(t *testing.T) { +func TestHubListCapsAtTenAndLeanCap(t *testing.T) { var hubs []scanner.HubEntry var paths []string for i := 0; i < 30; i++ { - p := fmt.Sprintf("src/some/deeply/nested/module_%02d/implementation.ts", i) + p := fmt.Sprintf("src/module_%02d/implementation.ts", i) paths = append(paths, p) hubs = append(hubs, scanner.HubEntry{Path: p, Files: []string{p}, Importers: 100 - i}) } - out := renderCompactTree(t, scanner.Project{Files: goFiles(paths...), Hubs: hubs, LineCounts: map[string]int{paths[0]: 12}, HubSymbols: map[string][]scanner.Symbol{}}) - if strings.Contains(out, "12L") { - t.Fatalf("line count under the floor shown:\n%s", out) - } - trailer := out[strings.Index(out, "⭐️ ←N"):] - if !strings.Contains(trailer, "Hubs (top 10 of 30): ") || !strings.Contains(trailer, "module_09/") || strings.Contains(trailer, "module_10/") { - t.Fatalf("hub list not capped:\n%s", out) + trailerOf := func(out string) string { return out[strings.Index(out, "⭐️ ←N"):] } + full := trailerOf(renderCompactTree(t, scanner.Project{Files: goFiles(paths...), Hubs: hubs})) + if !strings.Contains(full, "Hubs (top 10 of 30, by importers):\n ←100 src/module_00/") || + !strings.Contains(full, "\n ← 91 src/module_09/") || strings.Contains(full, "module_10/") { + t.Fatalf("hub list not capped at 10:\n%s", full) } - for _, line := range strings.Split(out, "\n") { - if n := len([]rune(line)); n > maxTrailerLine { - t.Fatalf("line of %d runes:\n%s", n, line) - } + lean := trailerOf(renderCompactTree(t, scanner.Project{Files: goFiles(paths...), Hubs: hubs, HubListMax: 3})) + if !strings.Contains(lean, "Hubs (top 3 of 30, by importers):") || strings.Contains(lean, "module_03/") { + t.Fatalf("lean hub list not capped at 3:\n%s", lean) } } @@ -230,7 +230,7 @@ func TestDiffTreeKeepsTestsAndStars(t *testing.T) { out := renderCompactTree(t, scanner.Project{DiffRef: "main", Files: []scanner.FileInfo{ {Path: "a.go", Ext: ".go", Size: 1, Added: 1}, {Path: "a_test.go", Ext: ".go", Size: 1, Added: 1}, }}) - if !strings.Contains(out, "a_test.go") || strings.Contains(out, "+1 test") || strings.Contains(out, "Top hubs") { + if !strings.Contains(out, "a_test.go") || strings.Contains(out, "+1 test") || strings.Contains(out, "Hubs (") { t.Fatalf("diff render changed:\n%s", out) } } diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 02f23d99..38e1c0a4 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -798,14 +798,12 @@ func analysesFromMatches(root string, matches []ScanMatch) []FileAnalysis { } else if strings.HasSuffix(m.RuleID, "-types") { if name := extractTypeName(m.Text); name != "" { fileMap[relPath].Types = append(fileMap[relPath].Types, name) - fileMap[relPath].recordSymbolLine(name, m.Range.Start.Line) } } else if strings.HasSuffix(m.RuleID, "-functions") { // Extract function name from text name := extractFunctionName(m.Text, fileMap[relPath].Language) if name != "" { fileMap[relPath].Functions = append(fileMap[relPath].Functions, name) - fileMap[relPath].recordSymbolLine(name, m.Range.Start.Line) } } } @@ -823,15 +821,6 @@ func analysesFromMatches(root string, matches []ScanMatch) []FileAnalysis { return results } -func (a *FileAnalysis) recordSymbolLine(name string, line int) { - if a.SymbolLines == nil { - a.SymbolLines = map[string]int{} - } - if prev, ok := a.SymbolLines[name]; !ok || line < prev { - a.SymbolLines[name] = line - } -} - func dedupeImportReferences(refs []ImportReference) []ImportReference { seen := make(map[ImportReference]bool) result := make([]ImportReference, 0, len(refs)) diff --git a/scanner/types.go b/scanner/types.go index 44b1b0e0..49093ffc 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -43,16 +43,10 @@ type Project struct { Hubs []HubEntry `json:"-"` HubNote string `json:"-"` // why Hubs is empty or partial UntrackedDirs map[string]bool `json:"-"` // slash paths of wholly untracked dirs - // Physical line counts per file, and up to three exported symbols for - // each of the top hub files. - LineCounts map[string]int `json:"-"` - HubSymbols map[string][]Symbol `json:"-"` -} - -// Symbol is a declaration at a 1-based line of a file. -type Symbol struct { - Name string - Line int + // LineCounts holds physical line counts per file; HubListMax caps the + // hub list under the tree (0 = the default of 10). + LineCounts map[string]int `json:"-"` + HubListMax int `json:"-"` } // ScanBound records a walk that stopped keeping files at Limit. @@ -86,9 +80,6 @@ type FileAnalysis struct { Types []string `json:"types,omitempty"` Imports []string `json:"imports"` References []ImportReference `json:"-"` - // SymbolLines maps each function and type name to the 0-based line of its - // first ast-grep match, as ast-grep reports it. - SymbolLines map[string]int `json:"-"` } // ImportReference preserves language-specific import semantics for graph resolution. diff --git a/skills/builtin/codemap.md b/skills/builtin/codemap.md index d2e71a45..5b3876dc 100644 --- a/skills/builtin/codemap.md +++ b/skills/builtin/codemap.md @@ -129,7 +129,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show ` for guida - `x.go (+3 tests)` folds same-directory tests into their source; `+N other tests` counts tests with no source beside them (a `tests/` dir lists its files) - `page-*.yml ×9` collapses 4+ siblings that differ only in numbers or hashes; `dir/ (untracked, N files, SIZE)` is a wholly untracked directory - Directories flattened when containing single subdirectory; a directory holding one file prints as `dir/FILE` -- After the tree: the legend, `Hubs (top 10 of N)` with full paths, importer counts and line counts, `path:line Name` for exported symbols of the top hub files, and the next commands +- After the tree: the legend, then `Hubs (top 10 of N, by importers):` with one hub per line (`←73 scanner/`, full path, line count when 100+), then the next commands ### Dependency Flow (`codemap --deps`) - External dependencies grouped by language diff --git a/tree_detail.go b/tree_detail.go index dbe944de..92c31384 100644 --- a/tree_detail.go +++ b/tree_detail.go @@ -2,25 +2,15 @@ package main import ( "bytes" - "context" "io" "os" "path/filepath" - "regexp" - "sort" "strings" - "unicode" - "unicode/utf8" "codemap/render" "codemap/scanner" ) -const ( - maxSymbolFiles = 5 - maxSymbolsPerFile = 3 -) - // countLines returns physical line counts for files that are not assets, // not binary (NUL in the first 8000 bytes) and at most render.MaxLineCountBytes. func countLines(root string, files []scanner.FileInfo) map[string]int { @@ -64,82 +54,3 @@ func readRegularFile(path string) ([]byte, bool) { } return data, true } - -// hubSymbols lists up to maxSymbolsPerFile exported top-level symbols for -// the first maxSymbolFiles single-file hubs. A multi-file Go package is -// skipped: its importers name the package, not one file. -func hubSymbols(root string, hubs []scanner.HubEntry) map[string][]scanner.Symbol { - sg, err := scanner.NewAstGrepScanner() - if err != nil || !sg.Available() { - return nil - } - defer sg.Close() - out := map[string][]scanner.Symbol{} - picked := 0 - for _, h := range hubs { - if h.IsPackage() { - continue - } - if picked == maxSymbolFiles { - break - } - picked++ - if syms := fileSymbols(sg, root, h.Path); len(syms) > 0 { - out[h.Path] = syms - } - } - return out -} - -// fileSymbols keeps a symbol only when the source line ast-grep reported -// names it, so a wrong line number drops the symbol instead of printing it. -func fileSymbols(sg *scanner.AstGrepScanner, root, rel string) []scanner.Symbol { - outcome, err := sg.ScanDirectory(context.Background(), filepath.Join(root, filepath.Dir(rel))) - if err != nil { - return nil - } - var analysis *scanner.FileAnalysis - for i := range outcome.Analyses { - if filepath.ToSlash(outcome.Analyses[i].Path) == pathBase(rel) { - analysis = &outcome.Analyses[i] - } - } - src, ok := readRegularFile(filepath.Join(root, rel)) - if analysis == nil || !ok { - return nil - } - lines := strings.Split(string(src), "\n") - var syms []scanner.Symbol - for name, line := range analysis.SymbolLines { - if line < 0 || line >= len(lines) { - continue - } - text := lines[line] - if !regexp.MustCompile(`\b`+regexp.QuoteMeta(name)+`\b`).MatchString(text) || !exportedTopLevel(analysis.Language, name, text) { - continue - } - syms = append(syms, scanner.Symbol{Name: name, Line: line + 1}) - } - sort.Slice(syms, func(i, j int) bool { return syms[i].Line < syms[j].Line }) - return syms[:min(len(syms), maxSymbolsPerFile)] -} - -func pathBase(rel string) string { return filepath.Base(filepath.FromSlash(rel)) } - -// exportedTopLevel: the declaration starts its line (no indentation) and is -// visible outside the file by the language's rule. -func exportedTopLevel(language, name, text string) bool { - if text == "" || text[0] == ' ' || text[0] == '\t' { - return false - } - switch language { - case "go": - r, _ := utf8.DecodeRuneInString(name) - return unicode.IsUpper(r) && !strings.HasPrefix(text, "func (") - case "python": - return !strings.HasPrefix(name, "_") - case "typescript", "javascript": - return strings.HasPrefix(text, "export ") - } - return true -} diff --git a/tree_detail_test.go b/tree_detail_test.go index e2df80cf..d659a8ed 100644 --- a/tree_detail_test.go +++ b/tree_detail_test.go @@ -49,24 +49,3 @@ func TestCountLinesSkipsSymlinkOutsideRoot(t *testing.T) { t.Fatalf("symlink counted: %v", got) } } - -func TestExportedTopLevel(t *testing.T) { - cases := []struct { - lang, name, text string - want bool - }{ - {"go", "Load", "func Load(root string) Config {", true}, - {"go", "load", "func load() {", false}, - {"go", "String", "func (c Config) String() string {", false}, - {"python", "run", "def run():", true}, - {"python", "_private", "def _private():", false}, - {"python", "method", " def method(self):", false}, - {"typescript", "debug", "export function debug() {", true}, - {"typescript", "helper", "function helper() {", false}, - } - for _, c := range cases { - if got := exportedTopLevel(c.lang, c.name, c.text); got != c.want { - t.Errorf("exportedTopLevel(%q, %q, %q) = %v, want %v", c.lang, c.name, c.text, got, c.want) - } - } -}