diff --git a/.claude/skills/codemap/SKILL.md b/.claude/skills/codemap/SKILL.md index 53720a0..054a99e 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, 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 aae2cf2..3ff33b8 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" 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). 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 0000000..d1161b5 --- /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 8932d96..d56b5b7 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 57c56c7..4536917 100644 --- a/main.go +++ b/main.go @@ -624,6 +624,14 @@ func main() { } else { if diffInfo == nil { project.Hubs, project.HubNote = treeHubs(absRoot, len(files), filters) + project.UntrackedDirs = untrackedDirs(root) + lean := os.Getenv(cmd.TreeLeanEnv) + if lean != "2" { + project.LineCounts = countLines(absRoot, files) + } + if lean != "" { + project.HubListMax = 3 + } } render.Tree(os.Stdout, project) } diff --git a/mcp/main.go b/mcp/main.go index 4a1afee..de6711c 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 f3a6efe..e8edc39 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 53720a0..054a99e 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, 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.go b/render/tree.go index dccfd49..0170517 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 0000000..ea5af5c --- /dev/null +++ b/render/tree_compact.go @@ -0,0 +1,554 @@ +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 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 != "" { + 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 + } + limit := project.HubListMax + if limit <= 0 { + limit = maxTrailerHubs + } + 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 { + 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 diff --git a/render/tree_compact_test.go b/render/tree_compact_test.go new file mode 100644 index 0000000..68de7c8 --- /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", + "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) { + 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, "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 TestDetailTreeShowsLinesAndHubRows(t *testing.T) { + out := renderCompactTree(t, scanner.Project{ + 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}, + }) + 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 TestHubListCapsAtTenAndLeanCap(t *testing.T) { + var hubs []scanner.HubEntry + var paths []string + for i := 0; i < 30; 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}) + } + 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) + } + 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) + } +} + +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, "Hubs (") { + t.Fatalf("diff render changed:\n%s", out) + } +} diff --git a/render/tree_render_test.go b/render/tree_render_test.go index 1992d6e..ccc3466 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/types.go b/scanner/types.go index ec1646f..49093ff 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -38,10 +38,15 @@ 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 + // 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. diff --git a/skills/builtin/codemap.md b/skills/builtin/codemap.md index 3e814f3..5b3876d 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, 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 new file mode 100644 index 0000000..92c3138 --- /dev/null +++ b/tree_detail.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + + "codemap/render" + "codemap/scanner" +) + +// 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 +} diff --git a/tree_detail_test.go b/tree_detail_test.go new file mode 100644 index 0000000..d659a8e --- /dev/null +++ b/tree_detail_test.go @@ -0,0 +1,51 @@ +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) + } +} diff --git a/tree_hubs.go b/tree_hubs.go index ae9dc84..b45a8c4 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 0000000..433be8b --- /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") + } +}