From e7b1ae1fc479db6ab9767506dd77ed23777b9c15 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:52:25 +0200 Subject: [PATCH 1/2] cli: introduce minimal command registry for dispatch --- internal/cli/app.go | 42 +--------- internal/cli/commands.go | 162 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 38 deletions(-) create mode 100644 internal/cli/commands.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 56b3d0a..e9f0b02 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -62,46 +62,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { logger := logging.New(globalCfg.DefaultLogLevel, stderr) _ = logger - switch args[0] { - case "install": - return runInstall(stdout) - case "index": - return runIndex(ctx, globalCfg, stdout, args[1:], false) - case "update": - return runIndex(ctx, globalCfg, stdout, args[1:], true) - case "stats": - return runStats(ctx, globalCfg, stdout, args[1:]) - case "find-symbol": - return runQueryCommand(ctx, globalCfg, stdout, "find-symbol", args[1:]) - case "callers": - return runQueryCommand(ctx, globalCfg, stdout, "callers", args[1:]) - case "callees": - return runQueryCommand(ctx, globalCfg, stdout, "callees", args[1:]) - case "impact": - return runQueryCommand(ctx, globalCfg, stdout, "impact", args[1:]) - case "search": - return runQueryCommand(ctx, globalCfg, stdout, "search", args[1:]) - case "doctor": - return runDoctor(stdout, args[1:]) - case "config": - return runConfig(globalCfg, stdout, args[1:]) - case "benchmark": - return runBenchmark(ctx, stdout, args[1:]) - case "serve": - return runServe(ctx, globalCfg, stdout, stderr, args[1:]) - case "watch": - return runWatch(ctx, globalCfg, stdout, args[1:]) - case "graph": - return runGraph(ctx, globalCfg, stdout, args[1:]) - case "clean": - return runClean(ctx, globalCfg, stdout, args[1:]) - case "affected-tests": - return runAffectedTests(ctx, globalCfg, stdout, args[1:]) - case "visualize": - return runVisualize(ctx, globalCfg, stdout, args[1:]) - default: + cmd, ok := lookupCommand(args[0]) + if !ok { return fmt.Errorf("unknown command %q", args[0]) } + + return cmd.run(ctx, globalCfg, stdout, stderr, args[1:]) } func runDoctor(stdout io.Writer, args []string) error { diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..ff27cf7 --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,162 @@ +package cli + +import ( + "context" + "io" + + "github.com/isink17/codegraph/internal/config" +) + +type command struct { + name string + aliases []string + description string + run func(context.Context, config.Config, io.Writer, io.Writer, []string) error +} + +var commandByName = newCommandRegistry() + +func lookupCommand(name string) (*command, bool) { + c, ok := commandByName[name] + return c, ok +} + +func newCommandRegistry() map[string]*command { + reg := map[string]*command{} + + add := func(c *command) { + reg[c.name] = c + for _, a := range c.aliases { + reg[a] = c + } + } + + add(&command{ + name: "install", + description: "install codegraph", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runInstall(stdout) + }, + }) + add(&command{ + name: "index", + description: "index a repository", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, false) + }, + }) + add(&command{ + name: "update", + description: "incrementally update an index", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, true) + }, + }) + add(&command{ + name: "stats", + description: "show index stats", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runStats(ctx, cfg, stdout, args) + }, + }) + add(&command{ + name: "find-symbol", + description: "find symbols by name", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "find-symbol", args) + }, + }) + add(&command{ + name: "callers", + description: "find callers of a symbol", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callers", args) + }, + }) + add(&command{ + name: "callees", + description: "find callees of a symbol", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callees", args) + }, + }) + add(&command{ + name: "impact", + description: "compute impact radius", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "impact", args) + }, + }) + add(&command{ + name: "search", + description: "search", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "search", args) + }, + }) + add(&command{ + name: "doctor", + description: "run diagnostics", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runDoctor(stdout, args) + }, + }) + add(&command{ + name: "config", + description: "config commands", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runConfig(cfg, stdout, args) + }, + }) + add(&command{ + name: "benchmark", + description: "benchmarks", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runBenchmark(ctx, stdout, args) + }, + }) + add(&command{ + name: "serve", + description: "start MCP server", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runServe(ctx, cfg, stdout, stderr, args) + }, + }) + add(&command{ + name: "watch", + description: "watch repository and update index", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runWatch(ctx, cfg, stdout, args) + }, + }) + add(&command{ + name: "graph", + description: "graph commands", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runGraph(ctx, cfg, stdout, args) + }, + }) + add(&command{ + name: "clean", + description: "clean index data", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runClean(ctx, cfg, stdout, args) + }, + }) + add(&command{ + name: "affected-tests", + description: "find affected tests", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runAffectedTests(ctx, cfg, stdout, args) + }, + }) + add(&command{ + name: "visualize", + description: "generate interactive graph HTML", + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runVisualize(ctx, cfg, stdout, args) + }, + }) + + return reg +} From 9277b0f70968b13c1f702712ffaf531759330c5b Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:02:25 +0200 Subject: [PATCH 2/2] cli: fail-fast command registry and drive root usage from registry --- .gitignore | 1 + internal/cli/app.go | 41 ++--- internal/cli/commands.go | 331 ++++++++++++++++++++-------------- internal/cli/commands_test.go | 27 +++ 4 files changed, 239 insertions(+), 161 deletions(-) create mode 100644 internal/cli/commands_test.go diff --git a/.gitignore b/.gitignore index 4576974..22f2942 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage.out *.swo TODO.md GEMINI.md +ANALYSIS.md codegraph codegraph.exe codegraph.sqlite diff --git a/internal/cli/app.go b/internal/cli/app.go index e9f0b02..1f28856 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1133,33 +1133,18 @@ func runAffectedTests(ctx context.Context, cfg config.Config, stdout io.Writer, } func printUsage(w io.Writer) { - for _, line := range []string{ - "codegraph commands:", - " install", - " index ", - " update ", - " add --jsonl for streaming line-delimited JSON events", - " serve --repo-root ", - " stats ", - " find-symbol ", - " search ", - " callers --symbol ", - " callees --symbol ", - " impact [--symbol ] [--file ]", - " doctor", - " add --fix for non-destructive autofixes", - " config ", - " config init [--repo PATH] [--force]", - " benchmark [--count N] [--benchtime DURATION] [--save-baseline]", - " graph export [--format json|dot]", - " watch ", - " add --jsonl for streaming line-delimited JSON events", - " affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...", - " find tests affected by changed files; pipe from git diff --name-only", - " visualize [--repo-root PATH] [--symbol NAME] [--depth N] [--output FILE]", - " interactive D3.js graph visualization; opens browser or writes HTML file", - " clean [repo-path] [--vacuum]", - } { - fmt.Fprintln(w, line) + fmt.Fprintln(w, "codegraph commands:") + for _, cmd := range commandList { + lines := cmd.usageLines + if len(lines) == 0 { + lines = []string{" " + cmd.name} + } + for i, line := range lines { + if i == 0 && cmd.description != "" { + fmt.Fprintf(w, "%s - %s\n", line, cmd.description) + continue + } + fmt.Fprintln(w, line) + } } } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index ff27cf7..4a0ef76 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "io" "github.com/isink17/codegraph/internal/config" @@ -11,152 +12,216 @@ type command struct { name string aliases []string description string + usageLines []string run func(context.Context, config.Config, io.Writer, io.Writer, []string) error } -var commandByName = newCommandRegistry() +var ( + commandList = newCommandList() + commandByName = newCommandRegistry(commandList) +) func lookupCommand(name string) (*command, bool) { c, ok := commandByName[name] return c, ok } -func newCommandRegistry() map[string]*command { +func newCommandRegistry(cmds []*command) map[string]*command { reg := map[string]*command{} - add := func(c *command) { - reg[c.name] = c - for _, a := range c.aliases { - reg[a] = c + for _, c := range cmds { + registerCommand(reg, c) + } + + return reg +} + +func registerCommand(reg map[string]*command, c *command) { + if c == nil { + panic("command registry: nil command") + } + if c.name == "" { + panic("command registry: empty command name") + } + registerKey := func(key string) { + if key == "" { + panic(fmt.Sprintf("command registry: empty key for command %q", c.name)) + } + if prev, exists := reg[key]; exists { + panic(fmt.Sprintf("command registry: duplicate key %q for command %q (already used by %q)", key, c.name, prev.name)) } + reg[key] = c } - add(&command{ - name: "install", - description: "install codegraph", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runInstall(stdout) - }, - }) - add(&command{ - name: "index", - description: "index a repository", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runIndex(ctx, cfg, stdout, args, false) - }, - }) - add(&command{ - name: "update", - description: "incrementally update an index", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runIndex(ctx, cfg, stdout, args, true) - }, - }) - add(&command{ - name: "stats", - description: "show index stats", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runStats(ctx, cfg, stdout, args) - }, - }) - add(&command{ - name: "find-symbol", - description: "find symbols by name", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runQueryCommand(ctx, cfg, stdout, "find-symbol", args) - }, - }) - add(&command{ - name: "callers", - description: "find callers of a symbol", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runQueryCommand(ctx, cfg, stdout, "callers", args) - }, - }) - add(&command{ - name: "callees", - description: "find callees of a symbol", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runQueryCommand(ctx, cfg, stdout, "callees", args) - }, - }) - add(&command{ - name: "impact", - description: "compute impact radius", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runQueryCommand(ctx, cfg, stdout, "impact", args) - }, - }) - add(&command{ - name: "search", - description: "search", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runQueryCommand(ctx, cfg, stdout, "search", args) - }, - }) - add(&command{ - name: "doctor", - description: "run diagnostics", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runDoctor(stdout, args) - }, - }) - add(&command{ - name: "config", - description: "config commands", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runConfig(cfg, stdout, args) - }, - }) - add(&command{ - name: "benchmark", - description: "benchmarks", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runBenchmark(ctx, stdout, args) - }, - }) - add(&command{ - name: "serve", - description: "start MCP server", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runServe(ctx, cfg, stdout, stderr, args) - }, - }) - add(&command{ - name: "watch", - description: "watch repository and update index", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runWatch(ctx, cfg, stdout, args) - }, - }) - add(&command{ - name: "graph", - description: "graph commands", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runGraph(ctx, cfg, stdout, args) - }, - }) - add(&command{ - name: "clean", - description: "clean index data", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runClean(ctx, cfg, stdout, args) - }, - }) - add(&command{ - name: "affected-tests", - description: "find affected tests", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runAffectedTests(ctx, cfg, stdout, args) - }, - }) - add(&command{ - name: "visualize", - description: "generate interactive graph HTML", - run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { - return runVisualize(ctx, cfg, stdout, args) - }, - }) + registerKey(c.name) + for _, a := range c.aliases { + registerKey(a) + } +} - return reg +func newCommandList() []*command { + return []*command{ + { + name: "install", + description: "install codegraph", + usageLines: []string{" install"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runInstall(stdout) + }, + }, + { + name: "index", + description: "index a repository", + usageLines: []string{" index "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, false) + }, + }, + { + name: "update", + description: "incrementally update an index", + usageLines: []string{ + " update ", + " add --jsonl for streaming line-delimited JSON events", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, true) + }, + }, + { + name: "stats", + description: "show index stats", + usageLines: []string{" stats "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runStats(ctx, cfg, stdout, args) + }, + }, + { + name: "find-symbol", + description: "find symbols by name", + usageLines: []string{" find-symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "find-symbol", args) + }, + }, + { + name: "callers", + description: "find callers of a symbol", + usageLines: []string{" callers --symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callers", args) + }, + }, + { + name: "callees", + description: "find callees of a symbol", + usageLines: []string{" callees --symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callees", args) + }, + }, + { + name: "impact", + description: "compute impact radius", + usageLines: []string{" impact [--symbol ] [--file ]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "impact", args) + }, + }, + { + name: "search", + description: "search", + usageLines: []string{" search "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "search", args) + }, + }, + { + name: "doctor", + description: "run diagnostics", + usageLines: []string{ + " doctor", + " add --fix for non-destructive autofixes", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runDoctor(stdout, args) + }, + }, + { + name: "config", + description: "config commands", + usageLines: []string{ + " config ", + " config init [--repo PATH] [--force]", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runConfig(cfg, stdout, args) + }, + }, + { + name: "benchmark", + description: "benchmarks", + usageLines: []string{" benchmark [--count N] [--benchtime DURATION] [--save-baseline]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runBenchmark(ctx, stdout, args) + }, + }, + { + name: "serve", + description: "start MCP server", + usageLines: []string{" serve --repo-root "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runServe(ctx, cfg, stdout, stderr, args) + }, + }, + { + name: "watch", + description: "watch repository and update index", + usageLines: []string{ + " watch ", + " add --jsonl for streaming line-delimited JSON events", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runWatch(ctx, cfg, stdout, args) + }, + }, + { + name: "graph", + description: "graph commands", + usageLines: []string{" graph export [--format json|dot]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runGraph(ctx, cfg, stdout, args) + }, + }, + { + name: "clean", + description: "clean index data", + usageLines: []string{" clean [repo-path] [--vacuum]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runClean(ctx, cfg, stdout, args) + }, + }, + { + name: "affected-tests", + description: "find affected tests", + usageLines: []string{ + " affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...", + " find tests affected by changed files; pipe from git diff --name-only", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runAffectedTests(ctx, cfg, stdout, args) + }, + }, + { + name: "visualize", + description: "generate interactive graph HTML", + usageLines: []string{ + " visualize [--repo-root PATH] [--symbol NAME] [--depth N] [--output FILE]", + " interactive D3.js graph visualization; opens browser or writes HTML file", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runVisualize(ctx, cfg, stdout, args) + }, + }, + } } diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..173bdc3 --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" +) + +func TestRegisterCommandPanicsOnDuplicate(t *testing.T) { + reg := map[string]*command{} + registerCommand(reg, &command{name: "a"}) + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on duplicate key, got nil") + } + }() + registerCommand(reg, &command{name: "a"}) +} + +func TestRegisterCommandPanicsOnAliasDuplicate(t *testing.T) { + reg := map[string]*command{} + registerCommand(reg, &command{name: "a", aliases: []string{"x"}}) + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on duplicate alias key, got nil") + } + }() + registerCommand(reg, &command{name: "b", aliases: []string{"x"}}) +}