Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repository>` | What the indexer found in one of them |
| `sourceant architecture <repository>` | Indexed components and dependencies; compare an exported baseline with `--baseline` |
| `sourceant ui` | Open the graph in a browser |
| `sourceant version` | What this build is |

Expand Down
26 changes: 26 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions internal/agent/architecture.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions internal/agent/repositories.go
Original file line number Diff line number Diff line change
@@ -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
}
121 changes: 121 additions & 0 deletions internal/command/architecture.go
Original file line number Diff line number Diff line change
@@ -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 <repository>", 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
}
42 changes: 42 additions & 0 deletions internal/command/architecture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading