Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/codemap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show <name>` 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`)
Expand Down
32 changes: 30 additions & 2 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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 ""
}
Expand All @@ -513,14 +524,31 @@ 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))

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 {
Expand Down
9 changes: 9 additions & 0 deletions cmd/hooks_tree_hubs_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion plugins/codemap/skills/codemap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Skills matched: hub-safety, refactor - run `codemap skill show <name>` 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`)
Expand Down
92 changes: 54 additions & 38 deletions render/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -99,14 +87,15 @@ func buildTreeStructure(files []scanner.FileInfo) *treeNode {
// File
current.children[remaining] = &treeNode{
name: remaining,
path: filepath.ToSlash(f.Path),
isFile: true,
file: f,
}
break
}
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:]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
72 changes: 72 additions & 0 deletions render/tree_hubs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions render/tree_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/") {
Expand All @@ -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)",
}
Expand Down
Loading
Loading