From 50a3945d1d2d2687813e934bfade3fa67609cde6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 25 Sep 2026 00:37:27 +0100 Subject: [PATCH] feat(cli): Add architecture inspection and local MCP access --- README.md | 1 + docs/architecture.md | 26 +++ internal/agent/architecture.go | 42 ++++ internal/agent/repositories.go | 36 ++++ internal/command/architecture.go | 121 ++++++++++++ internal/command/architecture_test.go | 42 ++++ internal/command/mcp.go | 181 ++++++++++++++++++ internal/command/mcp_test.go | 63 ++++++ internal/command/repo.go | 34 ++++ internal/command/repo_test.go | 44 +++++ internal/command/root.go | 3 + .../testdata/architecture-comparison.json | 7 + internal/command/testdata/architecture.json | 66 +++++++ internal/command/testdata/mcp-initialize.json | 1 + internal/command/testdata/registered.json | 4 + 15 files changed, 671 insertions(+) create mode 100644 docs/architecture.md create mode 100644 internal/agent/architecture.go create mode 100644 internal/agent/repositories.go create mode 100644 internal/command/architecture.go create mode 100644 internal/command/architecture_test.go create mode 100644 internal/command/mcp.go create mode 100644 internal/command/mcp_test.go create mode 100644 internal/command/repo.go create mode 100644 internal/command/repo_test.go create mode 100644 internal/command/testdata/architecture-comparison.json create mode 100644 internal/command/testdata/architecture.json create mode 100644 internal/command/testdata/mcp-initialize.json create mode 100644 internal/command/testdata/registered.json diff --git a/README.md b/README.md index 862f1fd..72a482a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not | `sourceant status` | Whether the agent and the indexer are running | | `sourceant repos` | Repositories indexed on this machine | | `sourceant graph ` | What the indexer found in one of them | +| `sourceant architecture ` | Indexed components and dependencies; compare an exported baseline with `--baseline` | | `sourceant ui` | Open the graph in a browser | | `sourceant version` | What this build is | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ad0cf44 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,26 @@ +# Read code components + +Read a repository's indexed components and their dependencies: + +```sh +sourceant architecture acme/billing +sourceant architecture acme/billing --depth 2 +``` + +Components follow directory boundaries. Their identifiers remain the same when an unrelated component grows. Depth is between 1 and 4; tests are excluded unless `--tests` is supplied. + +Export a baseline: + +```sh +sourceant architecture acme/billing --depth 2 --json > architecture.json +``` + +After the repository is indexed again, compare it with that baseline: + +```sh +sourceant architecture acme/billing --baseline architecture.json +``` + +The comparison uses the baseline's repository, depth, and test selection. It reports added, removed, and modified components and dependencies. Incomplete snapshots are refused because missing code cannot establish that a dependency was removed. + +These commands read the current index. They do not trigger indexing or compare Git commits. The agent's schedule or the Repositories page updates the index. No model is called to group or compare components. diff --git a/internal/agent/architecture.go b/internal/agent/architecture.go new file mode 100644 index 0000000..60d90da --- /dev/null +++ b/internal/agent/architecture.go @@ -0,0 +1,42 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +func (c *Client) Architecture(ctx context.Context, repository string, depth int, includeTests bool) (json.RawMessage, error) { + return get[json.RawMessage](ctx, c, "/api/architecture", url.Values{ + "repository": {repository}, "depth": {strconv.Itoa(depth)}, "include_tests": {strconv.FormatBool(includeTests)}, + }) +} + +func (c *Client) CompareArchitecture(ctx context.Context, baseline json.RawMessage) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/architecture/compare", bytes.NewReader(baseline)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return nil, &Unreachable{BaseURL: c.baseURL, Cause: err} + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, &Error{StatusCode: resp.StatusCode, Detail: detail(body)} + } + if !json.Valid(body) { + return nil, fmt.Errorf("the agent returned an invalid architecture comparison") + } + return json.RawMessage(body), nil +} diff --git a/internal/agent/repositories.go b/internal/agent/repositories.go new file mode 100644 index 0000000..6784e47 --- /dev/null +++ b/internal/agent/repositories.go @@ -0,0 +1,36 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +func (c *Client) Register(ctx context.Context, path, name string) (Repository, error) { + var repository Repository + body, err := json.Marshal(map[string]string{"path": path, "name": name}) + if err != nil { + return repository, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/repositories", bytes.NewReader(body)) + if err != nil { + return repository, err + } + req.Header.Set("Content-Type", "application/json") + response, err := c.http.Do(req) + if err != nil { + return repository, &Unreachable{BaseURL: c.baseURL, Cause: err} + } + defer func() { _ = response.Body.Close() }() + data, err := io.ReadAll(response.Body) + if err != nil { + return repository, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return repository, &Error{StatusCode: response.StatusCode, Detail: detail(data)} + } + err = json.Unmarshal(data, &repository) + return repository, err +} diff --git a/internal/command/architecture.go b/internal/command/architecture.go new file mode 100644 index 0000000..3526fa9 --- /dev/null +++ b/internal/command/architecture.go @@ -0,0 +1,121 @@ +package command + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/sourceant/cli/internal/presentation" + "github.com/spf13/cobra" +) + +func architectureCommand(opts *options) *cobra.Command { + var depth int + var includeTests bool + var baselinePath string + command := &cobra.Command{ + Use: "architecture ", Short: "Read components and their dependencies from the local index", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if depth < 1 || depth > 4 { + return fmt.Errorf("depth must be between 1 and 4") + } + var data json.RawMessage + var err error + if baselinePath == "" { + data, err = opts.client().Architecture(cmd.Context(), args[0], depth, includeTests) + } else { + if cmd.Flags().Changed("depth") || cmd.Flags().Changed("tests") { + return fmt.Errorf("a comparison uses the baseline's depth and test selection") + } + file, openErr := os.Open(baselinePath) + if openErr != nil { + return openErr + } + defer func() { _ = file.Close() }() + baseline, readErr := io.ReadAll(io.LimitReader(file, (8<<20)+1)) + if readErr != nil { + return readErr + } + if len(baseline) > 8<<20 { + return fmt.Errorf("baseline exceeds 8 MiB") + } + var header struct { + Repository string `json:"repository"` + } + if json.Unmarshal(baseline, &header) != nil || header.Repository != args[0] { + return fmt.Errorf("baseline must be a snapshot of %s", args[0]) + } + data, err = opts.client().CompareArchitecture(cmd.Context(), baseline) + } + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), data) + } + return showArchitecture(cmd.OutOrStdout(), data, baselinePath != "") + }, + } + command.Flags().IntVar(&depth, "depth", 1, "Directory depth to group by (1 to 4)") + command.Flags().BoolVar(&includeTests, "tests", false, "Include test code") + command.Flags().StringVar(&baselinePath, "baseline", "", "Compare the current index with a previously exported JSON snapshot") + return command +} + +func showArchitecture(out io.Writer, data json.RawMessage, comparison bool) error { + var result struct { + Components []struct { + ID string `json:"id"` + Name string `json:"name"` + Files int `json:"files"` + Incoming int `json:"incoming"` + Outgoing int `json:"outgoing"` + Status string `json:"status"` + } `json:"components"` + Relationships []struct { + Source string `json:"source"` + Target string `json:"target"` + SourceName string `json:"source_name"` + TargetName string `json:"target_name"` + Type string `json:"type"` + Status string `json:"status"` + } `json:"relationships"` + Coverage struct { + Truncated bool `json:"truncated"` + Unplaced int `json:"unplaced_nodes"` + Unresolved int `json:"unresolved_edges"` + } `json:"coverage"` + } + if err := json.Unmarshal(data, &result); err != nil { + return err + } + if comparison { + _, _ = fmt.Fprintf(out, "%d changed components, %d changed relationships\n\n", len(result.Components), len(result.Relationships)) + } else { + _, _ = fmt.Fprintf(out, "%d components, %d relationships in the current index\n\n", len(result.Components), len(result.Relationships)) + } + rows := make([][]string, 0, len(result.Components)) + names := make(map[string]string, len(result.Components)) + for _, part := range result.Components { + names[part.ID] = part.Name + rows = append(rows, []string{part.Name, fmt.Sprint(part.Files), fmt.Sprint(part.Incoming), fmt.Sprint(part.Outgoing), part.Status}) + } + presentation.Table(out, []string{"COMPONENT", "FILES", "INCOMING", "OUTGOING", "CHANGE"}, rows) + if len(result.Relationships) > 0 { + links := make([][]string, 0, len(result.Relationships)) + for _, edge := range result.Relationships { + source, target := names[edge.Source], names[edge.Target] + if comparison { + source, target = edge.SourceName, edge.TargetName + } + links = append(links, []string{source, target, edge.Type, edge.Status}) + } + _, _ = fmt.Fprintln(out) + presentation.Table(out, []string{"FROM", "TO", "RELATIONSHIP", "CHANGE"}, links) + } + if result.Coverage.Truncated || result.Coverage.Unplaced > 0 || result.Coverage.Unresolved > 0 { + _, _ = fmt.Fprintln(out, "\nThis index reading is incomplete and cannot establish architecture changes.") + } + return nil +} diff --git a/internal/command/architecture_test.go b/internal/command/architecture_test.go new file mode 100644 index 0000000..6ef95ee --- /dev/null +++ b/internal/command/architecture_test.go @@ -0,0 +1,42 @@ +package command + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestArchitectureSummarizesTheAgentSnapshot(t *testing.T) { + run := running(t, map[string]answer{"/api/architecture": {body: fixture(t, "architecture.json")}}) + stdout, stderr, code := run("architecture", "acme/billing") + if code != 0 { + t.Fatalf("%d: %s", code, stderr) + } + for _, word := range []string{"2 components", "1 relationships", "payments", "identity", "FILES"} { + if !strings.Contains(stdout, word) { + t.Errorf("missing %q from %s", word, stdout) + } + } + stdout, stderr, code = run("architecture", "acme/billing", "--json") + if code != 0 || !json.Valid([]byte(stdout)) || !strings.Contains(stdout, "fingerprint") { + t.Fatalf("invalid export: %s %s", stdout, stderr) + } +} + +func TestArchitectureComparesAnExportedBaseline(t *testing.T) { + run := running(t, map[string]answer{"/api/architecture/compare": {body: fixture(t, "architecture-comparison.json")}}) + stdout, stderr, code := run("architecture", "acme/billing", "--baseline", "testdata/architecture.json") + if code != 0 || !strings.Contains(stdout, "0 changed components") { + t.Fatalf("%d: %s %s", code, stdout, stderr) + } + for _, args := range [][]string{ + {"architecture", "other/repo", "--baseline", "testdata/architecture.json"}, + {"architecture", "acme/billing", "--baseline", "testdata/architecture.json", "--depth", "2"}, + {"architecture", "acme/billing", "--depth", "5"}, + } { + _, _, code = run(args...) + if code == 0 { + t.Fatalf("invalid request succeeded: %v", args) + } + } +} diff --git a/internal/command/mcp.go b/internal/command/mcp.go new file mode 100644 index 0000000..95c5191 --- /dev/null +++ b/internal/command/mcp.go @@ -0,0 +1,181 @@ +package command + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "os" + "strings" + "sync" + + "github.com/spf13/cobra" +) + +func mcpCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "mcp", Short: "Connect an MCP client over standard input and output", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + address := opts.agentURL + if !cmd.Flags().Changed("agent") && os.Getenv(EnvAgent) == "" && os.Getenv("SOURCEANT_UI_URL") != "" { + address = os.Getenv("SOURCEANT_UI_URL") + } + bridge := &mcpBridge{ + endpoint: strings.TrimRight(address, "/") + "/mcp/", + client: &http.Client{Timeout: opts.timeout}, + output: cmd.OutOrStdout(), + } + return bridge.run(cmd.Context(), cmd.InOrStdin(), cmd.ErrOrStderr()) + }, + } +} + +type rpcMessage struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result,omitempty"` +} + +type mcpBridge struct { + endpoint string + client *http.Client + output io.Writer + mu sync.Mutex + session string + version string +} + +const maxMCPMessage = 16 << 20 + +func (b *mcpBridge) emit(data []byte) error { + var compact bytes.Buffer + if err := json.Compact(&compact, data); err != nil { + return fmt.Errorf("the MCP server returned an invalid message: %w", err) + } + b.mu.Lock() + defer b.mu.Unlock() + _, err := fmt.Fprintln(b.output, compact.String()) + return err +} + +func (b *mcpBridge) forward(ctx context.Context, data []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + b.mu.Lock() + if b.session != "" { + req.Header.Set("Mcp-Session-Id", b.session) + } + if b.version != "" { + req.Header.Set("MCP-Protocol-Version", b.version) + } + b.mu.Unlock() + response, err := b.client.Do(req) + if err != nil { + return fmt.Errorf("cannot reach the agent MCP endpoint: %w", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("agent MCP endpoint returned HTTP %d", response.StatusCode) + } + if response.StatusCode == http.StatusAccepted || response.StatusCode == http.StatusNoContent { + return nil + } + b.mu.Lock() + if session := response.Header.Get("Mcp-Session-Id"); session != "" { + b.session = session + } + b.mu.Unlock() + emit := func(raw []byte) error { + var message rpcMessage + if err := json.Unmarshal(raw, &message); err != nil { + return err + } + if message.Result.ProtocolVersion != "" { + b.mu.Lock() + b.version = message.Result.ProtocolVersion + b.mu.Unlock() + } + return b.emit(raw) + } + contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")) + if contentType == "application/json" { + raw, err := io.ReadAll(io.LimitReader(response.Body, maxMCPMessage+1)) + if err != nil { + return err + } + if len(raw) > maxMCPMessage { + return fmt.Errorf("MCP response exceeds 16 MiB") + } + return emit(raw) + } + if contentType != "text/event-stream" { + return fmt.Errorf("unexpected MCP response type %q", contentType) + } + scanner := bufio.NewScanner(response.Body) + scanner.Buffer(make([]byte, 4096), maxMCPMessage) + var event strings.Builder + for scanner.Scan() { + line := scanner.Text() + if line == "" && event.Len() > 0 { + if err := emit([]byte(event.String())); err != nil { + return err + } + event.Reset() + } else if strings.HasPrefix(line, "data:") { + if event.Len() > 0 { + event.WriteByte('\n') + } + event.WriteString(strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + if event.Len() > maxMCPMessage { + return fmt.Errorf("MCP event exceeds 16 MiB") + } + } + } + return scanner.Err() +} + +func (b *mcpBridge) run(ctx context.Context, input io.Reader, stderr io.Writer) error { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), maxMCPMessage) + var pending sync.WaitGroup + defer pending.Wait() + for scanner.Scan() { + data := bytes.Clone(scanner.Bytes()) + if len(bytes.TrimSpace(data)) == 0 { + continue + } + var message rpcMessage + if err := json.Unmarshal(data, &message); err != nil { + return fmt.Errorf("invalid MCP input: %w", err) + } + if message.Method == "initialize" || len(message.ID) == 0 { + if err := b.forward(ctx, data); err != nil { + return err + } + continue + } + pending.Add(1) + go func() { + defer pending.Done() + if err := b.forward(ctx, data); err != nil { + reply, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": message.ID, "error": map[string]any{"code": -32000, "message": err.Error()}}) + if writeErr := b.emit(reply); writeErr != nil { + b.mu.Lock() + _, _ = fmt.Fprintln(stderr, writeErr) + b.mu.Unlock() + } + } + }() + } + return scanner.Err() +} diff --git a/internal/command/mcp_test.go b/internal/command/mcp_test.go new file mode 100644 index 0000000..eb7629f --- /dev/null +++ b/internal/command/mcp_test.go @@ -0,0 +1,63 @@ +package command + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMCPCommandUsesTheAgentAndKeepsStdoutAsProtocol(t *testing.T) { + wire := fixture(t, "mcp-initialize.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mcp/" || r.Method != http.MethodPost { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Accept") != "application/json, text/event-stream" { + t.Error("MCP response formats were not negotiated") + } + var request rpcMessage + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + return + } + if request.Method == "notifications/initialized" { + if r.Header.Get("MCP-Protocol-Version") != "2025-06-18" { + t.Error("negotiated protocol version was dropped") + } + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(wire) + })) + defer server.Close() + t.Setenv("SOURCEANT_UI_URL", "") + command := mcpCommand(&options{agentURL: server.URL, timeout: time.Second}) + command.SetArgs([]string{}) + command.SetIn(strings.NewReader("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"capability-check\",\"version\":\"1\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n")) + var stdout, stderr bytes.Buffer + command.SetOut(&stdout) + command.SetErr(&stderr) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(&stdout) + var response rpcMessage + if err := decoder.Decode(&response); err != nil { + t.Fatal(err) + } + if response.Result.ProtocolVersion != "2025-06-18" { + t.Fatalf("unexpected response: %+v", response) + } + if err := decoder.Decode(&response); err != io.EOF { + t.Fatalf("unexpected extra stdout: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %s", &stderr) + } +} diff --git a/internal/command/repo.go b/internal/command/repo.go new file mode 100644 index 0000000..99b0861 --- /dev/null +++ b/internal/command/repo.go @@ -0,0 +1,34 @@ +package command + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" +) + +func repoCommand(opts *options) *cobra.Command { + var name string + command := &cobra.Command{Use: "repo", Short: "Manage locally registered repositories"} + add := &cobra.Command{ + Use: "add ", Short: "Register a repository with the local agent", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := filepath.Abs(args[0]) + if err != nil { + return err + } + repository, err := opts.client().Register(cmd.Context(), path, name) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), repository) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Registered %s at %s\n", repository.Name, repository.Path) + return err + }, + } + add.Flags().StringVar(&name, "name", "", "Name used to address the repository") + command.AddCommand(add) + return command +} diff --git a/internal/command/repo_test.go b/internal/command/repo_test.go new file mode 100644 index 0000000..4707193 --- /dev/null +++ b/internal/command/repo_test.go @@ -0,0 +1,44 @@ +package command + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestRepoAddRegistersThroughTheAgent(t *testing.T) { + path := t.TempDir() + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if r.Method != http.MethodPost || r.URL.Path != "/api/repositories" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var request map[string]string + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + return + } + if request["path"] != filepath.Clean(path) || request["name"] != "local/capabilities" { + t.Errorf("unexpected registration: %v", request) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture(t, "registered.json")) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + code := Run([]string{"--agent", server.URL, "repo", "add", path, "--name", "local/capabilities", "--json"}, &stdout, &stderr) + if code != 0 || !called { + t.Fatalf("registration exited %d: %s", code, &stderr) + } + var result map[string]any + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["name"] != "local/capabilities" { + t.Fatalf("unexpected output: %s", &stdout) + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 570294b..a3a075e 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -56,7 +56,10 @@ func Run(args []string, stdout, stderr io.Writer) int { setupCommand(), statusCommand(opts), reposCommand(opts), + repoCommand(opts), + mcpCommand(opts), graphCommand(opts), + architectureCommand(opts), uiCommand(opts), versionCommand(), ) diff --git a/internal/command/testdata/architecture-comparison.json b/internal/command/testdata/architecture-comparison.json new file mode 100644 index 0000000..022c825 --- /dev/null +++ b/internal/command/testdata/architecture-comparison.json @@ -0,0 +1,7 @@ +{ + "repository": "acme/billing", + "before": "e3df98abd8989fdf8054f071ed13e5e151e596ddd25cdf4138bc59b5824d6a98", + "after": "e3df98abd8989fdf8054f071ed13e5e151e596ddd25cdf4138bc59b5824d6a98", + "components": [], + "relationships": [] +} diff --git a/internal/command/testdata/architecture.json b/internal/command/testdata/architecture.json new file mode 100644 index 0000000..0b6afb2 --- /dev/null +++ b/internal/command/testdata/architecture.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "repository": "acme/billing", + "grouping": { + "kind": "directory", + "depth": 1, + "include_tests": false + }, + "components": [ + { + "id": "part:9aec5ea70b9fc283cd2bc80f", + "name": "identity", + "path": "identity", + "files": 1, + "nodes": 2, + "sample_files": [ + "identity/user.py" + ], + "incoming": 1, + "outgoing": 0, + "fingerprint": "cc4018e22750c491eb0a6804b9b6ed5119b7ce90165a6d2656911f2057c48d11" + }, + { + "id": "part:0b121dabb6f66a3d527c7f07", + "name": "payments", + "path": "payments", + "files": 1, + "nodes": 2, + "sample_files": [ + "payments/charge.py" + ], + "incoming": 0, + "outgoing": 1, + "fingerprint": "e54ea556564b8e0f9b62d70c70dcd1e56a2d74dc57e15a8127be1dec1040b5b4" + } + ], + "relationships": [ + { + "source": "part:0b121dabb6f66a3d527c7f07", + "target": "part:9aec5ea70b9fc283cd2bc80f", + "type": "imports", + "count": 1, + "evidence": [ + { + "origin": "inferred", + "source": { + "path": "payments/charge.py", + "symbol": "file:payments/charge.py" + }, + "target": { + "path": "identity/user.py", + "symbol": "file:identity/user.py" + } + } + ] + } + ], + "coverage": { + "nodes": 4, + "files": 2, + "unplaced_nodes": 0, + "unresolved_edges": 0, + "truncated": false + }, + "fingerprint": "e3df98abd8989fdf8054f071ed13e5e151e596ddd25cdf4138bc59b5824d6a98" +} diff --git a/internal/command/testdata/mcp-initialize.json b/internal/command/testdata/mcp-initialize.json new file mode 100644 index 0000000..da00c76 --- /dev/null +++ b/internal/command/testdata/mcp-initialize.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"SourceAnt","version":"1.28.1"},"instructions":"An indexed graph of this codebase and the engineering knowledge recorded against it. Search and traverse code structure, read and write decisions, rules, constraints, conventions, API contracts, and system topology, and combine any of them into one bounded context pack. Write what you learn back so it outlives this session."}} \ No newline at end of file diff --git a/internal/command/testdata/registered.json b/internal/command/testdata/registered.json new file mode 100644 index 0000000..b25dc90 --- /dev/null +++ b/internal/command/testdata/registered.json @@ -0,0 +1,4 @@ +{ + "name": "local/capabilities", + "path": "/tmp/capability-repo" +}