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, "" +}