diff --git a/main.go b/main.go index d8b8aee9..373dd9d4 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "codemap/internal/buildinfo" "codemap/internal/projectpath" "codemap/limits" + codemapmcp "codemap/mcp" "codemap/render" "codemap/scanner" "codemap/watch" @@ -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" @@ -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") @@ -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) { @@ -305,6 +319,8 @@ func main() { fmt.Println(" --exclude Exclude paths matching patterns (e.g., '.xcassets,Fonts')") fmt.Println(" --stdin Read JSON file manifest from stdin (use with --deps)") fmt.Println(" --importers Check file impact (who imports it, hub status)") + fmt.Println(" --hubs List hub files (3+ importers)") + fmt.Println(" --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") @@ -367,6 +383,7 @@ 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") @@ -374,12 +391,16 @@ func main() { 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 = "." } @@ -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") @@ -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). @@ -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 { diff --git a/main_cli_rough_edges_test.go b/main_cli_rough_edges_test.go new file mode 100644 index 00000000..254cf66f --- /dev/null +++ b/main_cli_rough_edges_test.go @@ -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 ", "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()) + } +} diff --git a/mcp/main.go b/mcp/main.go index 452ce56a..6f93699a 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -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 @@ -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) { @@ -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) @@ -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 } } @@ -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 diff --git a/mcp/surface_hygiene_test.go b/mcp/surface_hygiene_test.go index a49dd156..67eef082 100644 --- a/mcp/surface_hygiene_test.go +++ b/mcp/surface_hygiene_test.go @@ -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}, diff --git a/topology/format.go b/topology/format.go index c0603379..0bb90947 100644 --- a/topology/format.go +++ b/topology/format.go @@ -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 @@ -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) {