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
73 changes: 71 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"codemap/internal/buildinfo"
"codemap/internal/projectpath"
"codemap/limits"
codemapmcp "codemap/mcp"
"codemap/render"
"codemap/scanner"
"codemap/watch"
Expand Down Expand Up @@ -80,6 +81,14 @@ func main() {
return
}

if len(os.Args) >= 2 && os.Args[1] == "status" {
if err := runStatusSubcommand(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}

// Handle "watch" subcommand before flag parsing
if len(os.Args) >= 2 && os.Args[1] == "watch" {
subCmd := "status"
Expand Down Expand Up @@ -267,6 +276,8 @@ func main() {
watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)")
stdinMode := flag.Bool("stdin", false, "Read file manifest from stdin (use with --deps)")
importersMode := flag.String("importers", "", "Check file impact: who imports it, is it a hub?")
hubsMode := flag.Bool("hubs", false, "List hub files (3+ importers)")
fileMode := flag.String("file", "", "Show imports, importers, and hub status for one file")
topologyMode := flag.Bool("topology", false, "Show project/module dependency topology")
moduleMode := flag.String("module", "", "Show context for one exact module ID or unique name")
moduleFileMode := flag.String("module-file", "", "Show context for the module owning a repository-relative file")
Expand All @@ -275,6 +286,9 @@ func main() {
flag.BoolVar(helpMode, "h", false, "Show help (shorthand)")
// Short flag aliases
flag.IntVar(depthLimit, "d", 0, "Limit tree depth (shorthand)")
flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), "Run 'codemap --help' for usage.")
}
flag.Parse()
refExplicit := false
flag.Visit(func(f *flag.Flag) {
Expand Down Expand Up @@ -305,6 +319,8 @@ func main() {
fmt.Println(" --exclude <patterns> Exclude paths matching patterns (e.g., '.xcassets,Fonts')")
fmt.Println(" --stdin Read JSON file manifest from stdin (use with --deps)")
fmt.Println(" --importers <file> Check file impact (who imports it, hub status)")
fmt.Println(" --hubs List hub files (3+ importers)")
fmt.Println(" --file <file> Imports, importers, and hub status for one file")
fmt.Println(" --json Output machine-readable JSON")
fmt.Println(" --debug Show scanner and path diagnostics")
fmt.Println(" --watch Run the live file watcher daemon")
Expand Down Expand Up @@ -367,19 +383,24 @@ func main() {
fmt.Println(" codemap context # Print machine-readable project context JSON")
fmt.Println(" codemap serve # Serve project intelligence over HTTP")
fmt.Println(" codemap version # Show build version")
fmt.Println(" codemap status # Version, project root, config, watch daemon")
fmt.Println()
fmt.Println("Recommended onboarding:")
fmt.Println(" codemap setup # Configure project and agent hooks")
fmt.Println(" codemap setup --global # Write hooks to ~/.claude/settings.json")
os.Exit(0)
}

root, importer, err := resolveImportersInvocation(flag.Arg(0), *importersMode, projectRootExplicit)
fileTarget := importersMode
if *fileTarget == "" {
fileTarget = fileMode
}
root, importer, err := resolveImportersInvocation(flag.Arg(0), *fileTarget, projectRootExplicit)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(2)
}
*importersMode = importer
*fileTarget = importer
if root == "" {
root = "."
}
Expand Down Expand Up @@ -482,6 +503,18 @@ func main() {
runImportersMode(absRoot, *importersMode, *jsonMode, filters)
return
}
if *hubsMode || *fileMode != "" {
text, err := codemapmcp.HubsText(context.Background(), absRoot, filters)
if *fileMode != "" {
text, err = codemapmcp.FileContextText(context.Background(), absRoot, *fileMode, filters)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error building file graph: %v\n", err)
os.Exit(1)
}
fmt.Print(text)
return
}

if *moduleMode != "" && *moduleFileMode != "" {
fmt.Fprintln(os.Stderr, "Error: specify either --module or --module-file, not both")
Expand Down Expand Up @@ -646,6 +679,17 @@ func applyGlobalRootOptions(args []string) ([]string, bool, error) {
}

func resolveImportersInvocation(root, importer string, projectRootExplicit bool) (string, string, error) {
// A relative file names a path from the working directory, so from a
// subdirectory the project is still the enclosing repository.
if root == "" && !projectRootExplicit && importer != "" && !filepath.IsAbs(importer) {
if abs, err := filepath.Abs(importer); err == nil {
if _, err := os.Stat(abs); err == nil {
if inferredRoot, file, err := resolveImportersInvocation("", abs, false); err == nil {
return inferredRoot, file, nil
}
}
}
}
if root != "" || projectRootExplicit || importer == "" || !filepath.IsAbs(importer) {
// Canonicalize the importer so the relative path matches the
// canonicalized project root (e.g. macOS /tmp -> /private/tmp).
Expand Down Expand Up @@ -867,6 +911,31 @@ func buildImportersReport(root, file string, filters scanner.Filters) (scanner.I
return buildImportersReportFromGraph(root, file, fg)
}

// runStatusSubcommand is the CLI side of the MCP status tool.
func runStatusSubcommand() error {
cwd, err := os.Getwd()
if err != nil {
return err
}
root, found, err := cmd.ResolveNearestGitRoot(cwd)
if err != nil {
return err
}
fmt.Printf("codemap %s\n", buildinfo.Current())
if !found {
fmt.Printf("Project root: %s (no enclosing repository)\n", cwd)
return nil
}
fmt.Printf("Project root: %s\n", root)
configPath := config.ConfigPath(root)
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config: %s\n", configPath)
} else {
fmt.Println("Config: none (run 'codemap config init')")
}
return runWatchSubcommand("status", root)
}

func runImportersMode(root, file string, jsonMode bool, filters scanner.Filters) {
report, err := buildImportersReport(root, file, filters)
if err != nil {
Expand Down
111 changes: 111 additions & 0 deletions main_cli_rough_edges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main

import (
"bytes"
"context"
"os/exec"
"path/filepath"
"strings"
"testing"

"codemap/topology"
)

// #289: the CLI routes that the MCP server already had, and the rough edges
// hit when running every command.

func runCodemapIn(t *testing.T, dir string, args ...string) (string, string, error) {
t.Helper()
command := exec.Command(codemapTestBinaryPath, args...)
command.Dir = dir
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
return stdout.String(), stderr.String(), err
}

func TestImportersFromSubdirectoryUsesEnclosingRepository(t *testing.T) {
repo := makeMainGitRepo(t, "main")
stdout, stderr, err := runCodemapIn(t, filepath.Join(repo, "pkg"), "--importers", "types/types.go")
if err != nil {
t.Fatalf("--importers from a subdirectory failed: %v\n%s", err, stderr)
}
if !strings.Contains(stdout, "pkg/types/types.go") || !strings.Contains(stdout, "HUB FILE") {
t.Fatalf("importers must resolve against the repository root:\n%s", stdout)
}
}

func TestMissingFlagValuePointsAtHelpInsteadOfDumpingUsage(t *testing.T) {
_, stderr, err := runCodemapWithInput("", "--importers")
if err == nil {
t.Fatal("expected a usage error")
}
if !strings.Contains(stderr, "Run 'codemap --help'") || strings.Contains(stderr, "-animate") {
t.Fatalf("flag error must be one hint, not the full flag dump:\n%s", stderr)
}
}

func TestHubsAndFileFlagsMatchMCPOutput(t *testing.T) {
repo := makeMainGitRepo(t, "main")
hubs, stderr, err := runCodemapIn(t, repo, "--hubs")
if err != nil {
t.Fatalf("--hubs failed: %v\n%s", err, stderr)
}
if !strings.Contains(hubs, "=== Hub Files (1 total) ===") || !strings.Contains(hubs, "pkg/types/types.go (4 importers)") {
t.Fatalf("--hubs output:\n%s", hubs)
}
file, stderr, err := runCodemapIn(t, repo, "--file", "pkg/types/types.go")
if err != nil {
t.Fatalf("--file failed: %v\n%s", err, stderr)
}
if !strings.Contains(file, "=== File Context: pkg/types/types.go ===") || !strings.Contains(file, "IMPORTED BY (4 files)") {
t.Fatalf("--file output:\n%s", file)
}
}

func TestHubsHonoursExcludeFlag(t *testing.T) {
repo := makeMainGitRepo(t, "main")
hubs, stderr, err := runCodemapIn(t, repo, "--hubs", "--exclude", "a,b")
if err != nil {
t.Fatalf("--hubs --exclude failed: %v\n%s", err, stderr)
}
if !strings.Contains(hubs, "No hub files found") {
t.Fatalf("--exclude must drop importers below the hub threshold:\n%s", hubs)
}
}

func TestStatusSubcommand(t *testing.T) {
repo := makeMainGitRepo(t, "main")
stdout, stderr, err := runCodemapIn(t, repo, "status")
if err != nil {
t.Fatalf("status failed: %v\n%s", err, stderr)
}
for _, want := range []string{"codemap ", "Project root: ", "Config: ", "Watch daemon "} {
if !strings.Contains(stdout, want) {
t.Fatalf("status output missing %q:\n%s", want, stdout)
}
}
help, _, _ := runCodemapWithInput("", "--help")
for _, want := range []string{"--hubs", "--file <file>", "codemap status"} {
if !strings.Contains(help, want) {
t.Fatalf("--help does not mention %q", want)
}
}
}

func TestTopologyWithNoModulesPointsAtDeps(t *testing.T) {
original := buildTopologyGraphOnly
buildTopologyGraphOnly = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) {
return &topology.Graph{Coverage: topology.Coverage{Status: topology.CoverageUnavailable}}, topology.CacheIdentity{}, nil
}
t.Cleanup(func() { buildTopologyGraphOnly = original })

var out bytes.Buffer
if err := runTopologyMode(context.Background(), "/repo", "", "", "", false, &out); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "Modules: 0") || !strings.Contains(out.String(), "codemap --deps") {
t.Fatalf("empty topology must explain itself:\n%s", out.String())
}
}
34 changes: 26 additions & 8 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1421,17 +1421,26 @@ func handleGetHubs(ctx context.Context, req *mcp.CallToolRequest, input PathInpu
if invalid != nil {
return invalid, nil, nil
}
fg, err := scanner.BuildFileGraph(ctx, absRoot, scanner.ConfiguredFilters(absRoot))
text, err := HubsText(ctx, absRoot, scanner.ConfiguredFilters(absRoot))
if err != nil {
if cancelled := cancellationResult(ctx, "Hub analysis"); cancelled != nil {
return cancelled, nil, nil
}
return errorResult("Failed to build file graph: " + err.Error()), nil, nil
}
return textResult(text), nil, nil
}

// HubsText renders the get_hubs report; the CLI --hubs flag prints it too.
func HubsText(ctx context.Context, absRoot string, filters scanner.Filters) (string, error) {
fg, err := scanner.BuildFileGraph(ctx, absRoot, filters)
if err != nil {
return "", err
}

hubs := fg.HubFiles()
if len(hubs) == 0 {
return textResult("No hub files found (no files with 3+ importers)." + mcpCoverageText(fg.Coverage)), nil, nil
return "No hub files found (no files with 3+ importers)." + mcpCoverageText(fg.Coverage), nil
}

// Sort by importer count
Expand Down Expand Up @@ -1460,7 +1469,7 @@ func handleGetHubs(ctx context.Context, req *mcp.CallToolRequest, input PathInpu
}
}

return textResult(sb.String() + mcpCoverageText(fg.Coverage)), nil, nil
return sb.String() + mcpCoverageText(fg.Coverage), nil
}

func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input ImportersInput) (*mcp.CallToolResult, any, error) {
Expand All @@ -1471,16 +1480,25 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I
if invalid != nil {
return invalid, nil, nil
}
filters := scanner.ConfiguredFilters(absRoot)
fg, err := scanner.BuildFileGraph(ctx, absRoot, filters)
text, err := FileContextText(ctx, absRoot, input.File, scanner.ConfiguredFilters(absRoot))
if err != nil {
if cancelled := cancellationResult(ctx, "File context analysis"); cancelled != nil {
return cancelled, nil, nil
}
return errorResult("Failed to build file graph: " + err.Error()), nil, nil
}
return textResult(text), nil, nil
}

file := normalizeImporterFile(absRoot, input.File)
// FileContextText renders the get_file_context report; the CLI --file flag
// prints it too.
func FileContextText(ctx context.Context, absRoot, file string, filters scanner.Filters) (string, error) {
fg, err := scanner.BuildFileGraph(ctx, absRoot, filters)
if err != nil {
return "", err
}

file = normalizeImporterFile(absRoot, file)
imports := append([]string(nil), fg.Imports[file]...)
importers := append([]string(nil), fg.Importers[file]...)
isHub := fg.IsHub(file)
Expand All @@ -1498,7 +1516,7 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I
if len(imports) == 0 && len(importers) == 0 {
if why, unindexed := fg.ExplainUnindexed(file, filters); unindexed {
sb.WriteString(notIndexedText(file, why, fg))
return textResult(sb.String()), nil, nil
return sb.String(), nil
}
}

Expand Down Expand Up @@ -1534,7 +1552,7 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I
sb.WriteString(fmt.Sprintf("CONNECTED: %d files in dependency graph\n", len(connected)))
sb.WriteString(mcpCoverageText(fg.FileCoverage(file)))

return textResult(sb.String()), nil, nil
return sb.String(), nil
}

// handleGetWorkingSet returns the current session's working set
Expand Down
4 changes: 2 additions & 2 deletions mcp/surface_hygiene_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ func TestTextResultCallerClassification(t *testing.T) {
"handleStartWatch": {3, 0},
"handleStopWatch": {3, 0},
"handleGetActivity": {2, 0},
"handleGetHubs": {2, 0},
"handleGetFileContext": {2, 0},
"handleGetHubs": {1, 0},
"handleGetFileContext": {1, 0},
"handleGetWorkingSet": {2, 0},
"handleListSkills": {2, 0},
"handleGetSkill": {1, 0},
Expand Down
7 changes: 6 additions & 1 deletion topology/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"strings"
)

const noModulesHint = "No modules found: topology reads multi-module manifests (Gradle, Maven, sbt, SwiftPM, Python). For a single-module project use 'codemap --deps'.\n"

func FormatGraph(graph *Graph, ecosystem string) string {
graph = FilterGraph(graph, ecosystem)
if graph == nil {
return "PROJECT TOPOLOGY\nCoverage: unavailable\nModules: 0\n"
return "PROJECT TOPOLOGY\nCoverage: unavailable\nModules: 0\n" + noModulesHint
}

var builder strings.Builder
Expand All @@ -22,6 +24,9 @@ func FormatGraph(graph *Graph, ecosystem string) string {
if len(graph.Coverage.Issues) > 0 {
_, _ = fmt.Fprintf(&builder, "Coverage notes: %d\n", len(graph.Coverage.Issues))
}
if len(graph.Nodes) == 0 {
builder.WriteString(noModulesHint)
}
builder.WriteString("\n")

for _, id := range sortedNodeIDs(graph.Nodes) {
Expand Down
Loading