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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ A checkout is reviewed here, by the same reviewer a pull request goes through. I

`/mcp` is proxied through to the core, so a coding agent on this machine reaches the same index and can ask for the same review.

The Graphs page also shows directory components and the source evidence for their dependencies. Export a JSON snapshot, or keep a baseline for comparison during the session. These readings use the current index and make no model calls. Incomplete snapshots cannot establish additions or removals. The same operations are available through `/api/architecture` and `/api/architecture/compare` for the CLI.

Loopback is the default because the agent reads a working tree. The machine it runs on is the only audience it has.

## Running it
Expand Down
64 changes: 64 additions & 0 deletions internal/api/architecture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package api

import (
"context"
"encoding/json"
"net/http"
"strconv"
)

type architectureReader interface {
Architecture(context.Context, string, int, bool) (json.RawMessage, error)
CompareArchitecture(context.Context, json.RawMessage) (json.RawMessage, error)
}

func (s *Server) architecture(w http.ResponseWriter, r *http.Request) {
reader, ok := s.reader.(architectureReader)
if !ok {
write(w, http.StatusNotImplemented, problem{Error: "This core does not support architecture readings"})
return
}
repository := r.URL.Query().Get("repository")
depth := 1
var err error
if given := r.URL.Query().Get("depth"); given != "" {
depth, err = strconv.Atoi(given)
}
if repository == "" || err != nil || depth < 1 || depth > 4 {
write(w, http.StatusBadRequest, problem{Error: "Name a repository and a depth between 1 and 4"})
return
}
includeTests := false
if given := r.URL.Query().Get("include_tests"); given != "" {
includeTests, err = strconv.ParseBool(given)
if err != nil {
write(w, http.StatusBadRequest, problem{Error: "include_tests must be true or false"})
return
}
}
result, err := reader.Architecture(r.Context(), repository, depth, includeTests)
if err != nil {
fail(w, err)
return
}
write(w, http.StatusOK, result)
}

func (s *Server) compareArchitecture(w http.ResponseWriter, r *http.Request) {
reader, ok := s.reader.(architectureReader)
if !ok {
write(w, http.StatusNotImplemented, problem{Error: "This core does not support architecture comparisons"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
var baseline json.RawMessage
if !readBody(w, r, &baseline) {
return
}
result, err := reader.CompareArchitecture(r.Context(), baseline)
if err != nil {
fail(w, err)
return
}
write(w, http.StatusOK, result)
}
92 changes: 92 additions & 0 deletions internal/api/architecture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package api

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/sourceant/agent/internal/core"
)

func TestArchitectureThroughAgent(t *testing.T) {
snapshot, err := os.ReadFile("../core/testdata/architecture.json")
if err != nil {
t.Fatal(err)
}
comparison, err := os.ReadFile("../core/testdata/architecture-comparison.json")
if err != nil {
t.Fatal(err)
}
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
result := snapshot
switch r.URL.Path {
case "/api/code/architecture":
if r.URL.Query().Get("repository") != "acme/billing" || r.URL.Query().Get("depth") != "2" {
t.Errorf("wrong query: %s", r.URL.RawQuery)
}
case "/api/code/architecture/compare":
if r.Method != http.MethodPost {
t.Errorf("wrong method: %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
var actual, expected any
_ = json.Unmarshal(body, &actual)
_ = json.Unmarshal(snapshot, &expected)
a, _ := json.Marshal(actual)
b, _ := json.Marshal(expected)
if !bytes.Equal(a, b) {
t.Error("the baseline changed in transit")
}
result = comparison
default:
t.Errorf("unexpected upstream route: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": json.RawMessage(result)})
}))
defer upstream.Close()
handler := New(core.New(upstream.URL, time.Second), nil, "test", upstream.URL).Handler()
for _, query := range []string{"", "?repository=acme/billing&depth=0", "?repository=acme/billing&include_tests=wrong"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/architecture"+query, nil))
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid request returned %d", response.Code)
}
}
if calls != 0 {
t.Fatal("invalid requests reached the core")
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/architecture?repository=acme/billing&depth=2", nil))
if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("payments")) {
t.Fatalf("reading: %d %s", response.Code, response.Body.String())
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/architecture/compare", bytes.NewReader(snapshot)))
if response.Code != http.StatusOK {
t.Fatalf("comparison: %d %s", response.Code, response.Body.String())
}
if calls != 2 {
t.Fatalf("got %d upstream requests, want 2", calls)
}
}

func TestArchitecturePreservesCoreDenial(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"detail":"local access only"}`))
}))
defer upstream.Close()
handler := New(core.New(upstream.URL, time.Second), nil, "test", upstream.URL).Handler()
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/architecture?repository=acme/billing", nil))
if response.Code != http.StatusForbidden {
t.Fatalf("got %d", response.Code)
}
}
13 changes: 13 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/repositories", s.dropRepository)
mux.HandleFunc("POST /api/index", s.index)
mux.HandleFunc("GET /api/graph", s.graph)
mux.HandleFunc("GET /api/architecture", s.architecture)
mux.HandleFunc("POST /api/architecture/compare", s.compareArchitecture)
mux.HandleFunc("GET /api/attention", s.attention)
mux.HandleFunc("GET /api/knowledge", s.knowledge)
mux.HandleFunc("PUT /api/knowledge", s.recordKnowledge)
Expand Down Expand Up @@ -174,10 +176,21 @@ func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
if err != nil {
limit = 0
}
depth := 2
if value := r.URL.Query().Get("depth"); value != "" {
depth, err = strconv.Atoi(value)
if err != nil || depth < 1 || depth > 5 {
write(w, http.StatusBadRequest, problem{Error: "depth must be between 1 and 5"})
return
}
}
graph, err := s.reader.Graph(r.Context(), repository, core.GraphOptions{
PathPrefix: r.URL.Query().Get("path_prefix"),
IncludeTests: r.URL.Query().Get("include_tests") == "true",
NodeLimit: limit,
Focus: r.URL.Query().Get("focus"),
Depth: depth,
Query: r.URL.Query().Get("q"),
})
if err != nil {
fail(w, err)
Expand Down
4 changes: 2 additions & 2 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,12 @@ func TestGraphPassesOnWhatNarrowsADrawing(t *testing.T) {
reader := &stubReader{}
server := New(reader, stubSupervisor{}, "dev", "")

call(t, server, "/api/graph?repository=acme/billing&path_prefix=app/&include_tests=true&node_limit=200")
call(t, server, "/api/graph?repository=acme/billing&path_prefix=app/&include_tests=true&node_limit=200&focus=file%3Aapp%2Fcharge.py&depth=3&q=charge")

if reader.askedFor != "acme/billing" {
t.Errorf("asked for %q, want acme/billing", reader.askedFor)
}
want := core.GraphOptions{PathPrefix: "app/", IncludeTests: true, NodeLimit: 200}
want := core.GraphOptions{PathPrefix: "app/", IncludeTests: true, NodeLimit: 200, Focus: "file:app/charge.py", Depth: 3, Query: "charge"}
if reader.askedOptions != want {
t.Errorf("asked with %+v, want %+v", reader.askedOptions, want)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/core/architecture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package core

import (
"context"
"encoding/json"
"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/code/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) {
return send[json.RawMessage](ctx, c, http.MethodPost, "/api/code/architecture/compare", nil, baseline)
}
12 changes: 12 additions & 0 deletions internal/core/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,23 @@ type GraphOptions struct {
PathPrefix string
IncludeTests bool
NodeLimit int
Focus string
Depth int
Query string
}

// Graph reads one repository's whole scope.
func (c *Client) Graph(ctx context.Context, repository string, opts GraphOptions) (Graph, error) {
query := url.Values{"repository": {repository}}
if opts.Focus != "" {
query.Set("focus", opts.Focus)
}
if opts.Depth > 0 {
query.Set("depth", strconv.Itoa(opts.Depth))
}
if opts.Query != "" {
query.Set("q", opts.Query)
}
if opts.PathPrefix != "" {
query.Set("path_prefix", opts.PathPrefix)
}
Expand Down
6 changes: 6 additions & 0 deletions internal/core/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ func TestGraphPassesOnWhatNarrowsADrawing(t *testing.T) {
PathPrefix: "src/config/",
IncludeTests: true,
NodeLimit: 120,
Focus: "file:app/charge.py",
Depth: 3,
Query: "charge",
})
if err != nil {
t.Fatalf("reading graph: %v", err)
Expand All @@ -163,6 +166,9 @@ func TestGraphPassesOnWhatNarrowsADrawing(t *testing.T) {
"path_prefix=src%2Fconfig%2F",
"include_tests=true",
"node_limit=120",
"focus=file%3Aapp%2Fcharge.py",
"depth=3",
"q=charge",
} {
if !strings.Contains(asked, want) {
t.Errorf("query %q is missing %q", asked, want)
Expand Down
7 changes: 7 additions & 0 deletions internal/core/testdata/architecture-comparison.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"repository": "acme/billing",
"before": "e3df98abd8989fdf8054f071ed13e5e151e596ddd25cdf4138bc59b5824d6a98",
"after": "e3df98abd8989fdf8054f071ed13e5e151e596ddd25cdf4138bc59b5824d6a98",
"components": [],
"relationships": []
}
66 changes: 66 additions & 0 deletions internal/core/testdata/architecture.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 2 additions & 0 deletions internal/ui/assets/assets/Architecture-zdoEceFG.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion internal/ui/assets/assets/index-BGVtFZd1.css

This file was deleted.

1 change: 1 addition & 0 deletions internal/ui/assets/assets/index-BqyDLv8O.css

Large diffs are not rendered by default.

Loading
Loading