From ad45eaef7141b593bcf1ad8fd3ec064ba454cd6b Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 11 Nov 2026 13:49:36 -0500 Subject: [PATCH] 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, "" +}