a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
1625 lines
50 KiB
Go
1625 lines
50 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
"github.com/zzet/gortex/internal/server/hub"
|
|
)
|
|
|
|
// activityBuffer holds the last N graph-change events so the UI can
|
|
// backfill its activity feed without waiting for a fresh event.
|
|
//
|
|
// The buffer is intentionally small (default 100) — it is meant for
|
|
// "what just happened" feedback in the dashboard, not durable history.
|
|
// Events are preserved across reconnects but are lost on server restart.
|
|
type activityBuffer struct {
|
|
mu sync.RWMutex
|
|
events []indexer.GraphChangeEvent
|
|
cap int
|
|
}
|
|
|
|
func newActivityBuffer(cap int) *activityBuffer {
|
|
if cap <= 0 {
|
|
cap = 100
|
|
}
|
|
return &activityBuffer{cap: cap, events: make([]indexer.GraphChangeEvent, 0, cap)}
|
|
}
|
|
|
|
func (b *activityBuffer) add(ev indexer.GraphChangeEvent) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.events = append(b.events, ev)
|
|
if len(b.events) > b.cap {
|
|
b.events = b.events[len(b.events)-b.cap:]
|
|
}
|
|
}
|
|
|
|
func (b *activityBuffer) snapshot(limit int) []indexer.GraphChangeEvent {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
if limit <= 0 || limit > len(b.events) {
|
|
limit = len(b.events)
|
|
}
|
|
out := make([]indexer.GraphChangeEvent, limit)
|
|
for i := 0; i < limit; i++ {
|
|
out[i] = b.events[len(b.events)-1-i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) startActivityCollector(eh *hub.Hub) {
|
|
if eh == nil || h.activity == nil {
|
|
return
|
|
}
|
|
subID, ch := eh.Subscribe()
|
|
go func() {
|
|
defer eh.Unsubscribe(subID)
|
|
for ev := range ch {
|
|
h.activity.add(ev)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// --- /v1/activity ---
|
|
|
|
func (h *Handler) handleActivity(w http.ResponseWriter, r *http.Request) {
|
|
limit := 50
|
|
if s := r.URL.Query().Get("limit"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
}
|
|
if h.activity == nil {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"events": []any{}})
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{"events": h.activity.snapshot(limit)})
|
|
}
|
|
|
|
// --- /v1/repos ---
|
|
//
|
|
// Returns a flat list of indexed repositories with the per-repo
|
|
// breakdown the dashboard's repo cards and the graph filter panel
|
|
// expect. All numbers are derived from graph.RepoStats() — no mock
|
|
// values. The "color" hint is chosen deterministically from the repo
|
|
// prefix so cards stay stable across reloads without storing state.
|
|
|
|
type repoEntry struct {
|
|
ID string `json:"id"`
|
|
Owner string `json:"owner"`
|
|
Lang string `json:"lang"`
|
|
Nodes int `json:"nodes"`
|
|
Edges int `json:"edges"`
|
|
Funcs int `json:"funcs"`
|
|
Methods int `json:"methods"`
|
|
Types int `json:"types"`
|
|
Interfaces int `json:"interfaces"`
|
|
Vars int `json:"vars"`
|
|
Files int `json:"files"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
// dominantLanguage returns the language with the highest byte/node share
|
|
// for a repo, ignoring config-only languages so a Go repo doesn't show
|
|
// up as "yaml".
|
|
func dominantLanguage(byLang map[string]int) string {
|
|
skip := map[string]bool{"json": true, "yaml": true, "toml": true, "markdown": true, "makefile": true, "dockerfile": true, "bash": true, "hcl": true}
|
|
best := ""
|
|
bestN := -1
|
|
for lang, n := range byLang {
|
|
if skip[lang] {
|
|
continue
|
|
}
|
|
if n > bestN {
|
|
best = lang
|
|
bestN = n
|
|
}
|
|
}
|
|
if best != "" {
|
|
return best
|
|
}
|
|
for lang, n := range byLang {
|
|
if n > bestN {
|
|
best = lang
|
|
bestN = n
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// hashColor maps a string to one of the design's accent OKLCH colors.
|
|
// Stable, deterministic, no per-repo seeding required.
|
|
var repoPalette = []string{
|
|
"oklch(0.82 0.15 155)",
|
|
"oklch(0.80 0.13 230)",
|
|
"oklch(0.78 0.14 300)",
|
|
"oklch(0.80 0.17 10)",
|
|
"oklch(0.82 0.14 45)",
|
|
"oklch(0.82 0.15 80)",
|
|
"oklch(0.72 0.02 252)",
|
|
}
|
|
|
|
func paletteFor(s string) string {
|
|
if s == "" {
|
|
return repoPalette[0]
|
|
}
|
|
var sum uint32
|
|
for _, c := range s {
|
|
sum = sum*31 + uint32(c)
|
|
}
|
|
return repoPalette[int(sum)%len(repoPalette)]
|
|
}
|
|
|
|
// splitOwner pulls "owner/repo" out of a repo prefix when one exists,
|
|
// otherwise treats the whole prefix as the repo and leaves owner blank.
|
|
func splitOwner(prefix string) (owner, name string) {
|
|
if before, after, ok := strings.Cut(prefix, "/"); ok {
|
|
return before, after
|
|
}
|
|
return "", prefix
|
|
}
|
|
|
|
func reposFromGraph(g graph.Store) []repoEntry {
|
|
stats := g.RepoStats()
|
|
out := make([]repoEntry, 0, len(stats))
|
|
for prefix, s := range stats {
|
|
owner, name := splitOwner(prefix)
|
|
out = append(out, repoEntry{
|
|
ID: name,
|
|
Owner: owner,
|
|
Lang: dominantLanguage(s.ByLanguage),
|
|
Nodes: s.TotalNodes,
|
|
Edges: s.TotalEdges,
|
|
Funcs: s.ByKind["function"],
|
|
Methods: s.ByKind["method"],
|
|
Types: s.ByKind["type"],
|
|
Interfaces: s.ByKind["interface"],
|
|
Vars: s.ByKind["variable"],
|
|
Files: s.ByKind["file"],
|
|
Color: paletteFor(prefix),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Nodes > out[j].Nodes })
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) handleRepos(w http.ResponseWriter, _ *http.Request) {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"repos": reposFromGraph(h.graph)})
|
|
}
|
|
|
|
// --- /v1/overlay/* ---
|
|
|
|
// handleOverlayRegister handles POST /v1/overlay/sessions.
|
|
//
|
|
// Body (all fields optional): {
|
|
//
|
|
// "workspace_id": "<slug>",
|
|
// "session_id": "<explicit-id>" // bind to a known MCP session
|
|
//
|
|
// }
|
|
//
|
|
// When `session_id` is supplied, the session is registered under that
|
|
// ID instead of a freshly minted one — this is how an MCP client binds
|
|
// its overlay session to its MCP session ID, so subsequent tools/call
|
|
// frames from the same MCP session automatically see the overlay
|
|
// (the MCP tool middleware reads SessionIDFromContext and resolves the
|
|
// overlay by that ID). Idempotent: registering twice with the same
|
|
// (id, workspace) tuple is a no-op; mismatched workspaces return 409.
|
|
//
|
|
// Response: {"session_id": "...", "workspace_id": "..."}.
|
|
func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) {
|
|
if h.overlays == nil {
|
|
http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
var body struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
if r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
id := body.SessionID
|
|
if id == "" {
|
|
id = h.overlays.Register(body.WorkspaceID)
|
|
} else {
|
|
if err := h.overlays.RegisterWithID(id, body.WorkspaceID); err != nil {
|
|
if errors.Is(err, daemon.ErrSessionExists) {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
WriteJSON(w, http.StatusCreated, map[string]any{
|
|
"session_id": id,
|
|
"workspace_id": body.WorkspaceID,
|
|
})
|
|
}
|
|
|
|
// handleOverlayPush handles PUT /v1/overlay/sessions/{id}/files.
|
|
// Body: OverlayFile JSON. Path is required; Content or Deleted=true.
|
|
func (h *Handler) handleOverlayPush(w http.ResponseWriter, r *http.Request) {
|
|
if h.overlays == nil {
|
|
http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
http.Error(w, "session id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var overlay daemon.OverlayFile
|
|
if err := json.NewDecoder(r.Body).Decode(&overlay); err != nil {
|
|
http.Error(w, "invalid OverlayFile JSON: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.overlays.Push(id, overlay, nil); err != nil {
|
|
switch {
|
|
case errors.Is(err, daemon.ErrSessionNotFound):
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
case errors.Is(err, daemon.ErrOverlayDrift):
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
default:
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleOverlayDelete handles DELETE /v1/overlay/sessions/{id}/files.
|
|
// Body: {"path": "..."}. Removes one overlay file from the session.
|
|
func (h *Handler) handleOverlayDelete(w http.ResponseWriter, r *http.Request) {
|
|
if h.overlays == nil {
|
|
http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
http.Error(w, "session id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Path == "" {
|
|
http.Error(w, "path is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.overlays.Delete(id, body.Path); err != nil {
|
|
if errors.Is(err, daemon.ErrSessionNotFound) {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleOverlayDrop handles DELETE /v1/overlay/sessions/{id}.
|
|
// Drops the entire session, discarding every overlay it held.
|
|
func (h *Handler) handleOverlayDrop(w http.ResponseWriter, r *http.Request) {
|
|
if h.overlays == nil {
|
|
http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
http.Error(w, "session id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h.overlays.Drop(id)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleOverlayList handles GET /v1/overlay/sessions/{id}/files.
|
|
// Returns the current overlay snapshot for the session.
|
|
func (h *Handler) handleOverlayList(w http.ResponseWriter, r *http.Request) {
|
|
if h.overlays == nil {
|
|
http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
http.Error(w, "session id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
files, err := h.overlays.Files(id)
|
|
if err != nil {
|
|
if errors.Is(err, daemon.ErrSessionNotFound) {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
out := make([]daemon.OverlayFile, 0, len(files))
|
|
for _, f := range files {
|
|
out = append(out, f)
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{"files": out})
|
|
}
|
|
|
|
// handleWorkspaceRoster serves `GET /v1/workspaces/{ws}/repos`.
|
|
// Returns the deduplicated list of repo prefixes for the requested
|
|
// workspace slug, or 404 when no node in
|
|
// the graph carries that workspace. The daemon-side
|
|
// WorkspaceRosterCache calls this once per (slug, workspace) and
|
|
// caches the result for ~1 minute (see daemon.NewWorkspaceRosterCache).
|
|
func (h *Handler) handleWorkspaceRoster(w http.ResponseWriter, r *http.Request) {
|
|
ws := r.PathValue("ws")
|
|
if ws == "" {
|
|
http.Error(w, "workspace slug required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
seen := make(map[string]struct{})
|
|
for _, n := range h.graph.AllNodes() {
|
|
// Effective workspace match — match on either the explicit
|
|
// WorkspaceID or the RepoPrefix fallback. Either way the
|
|
// result list reports RepoPrefix so callers can address the
|
|
// underlying repo without translating slugs.
|
|
nodeWS := n.WorkspaceID
|
|
if nodeWS == "" {
|
|
nodeWS = n.RepoPrefix
|
|
}
|
|
if nodeWS != ws {
|
|
continue
|
|
}
|
|
if n.RepoPrefix == "" {
|
|
continue
|
|
}
|
|
seen[n.RepoPrefix] = struct{}{}
|
|
}
|
|
if len(seen) == 0 {
|
|
http.Error(w, "workspace not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
repos := make([]string, 0, len(seen))
|
|
for p := range seen {
|
|
repos = append(repos, p)
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"workspace": ws,
|
|
"repos": repos,
|
|
})
|
|
}
|
|
|
|
// --- /v1/processes ---
|
|
//
|
|
// Thin wrapper around the get_processes MCP tool. Returns processes in
|
|
// a UI-friendly shape: each process gets a "crosses" array (unique repo
|
|
// prefixes touched) and a "risk" rating derived from the score. When
|
|
// the underlying tool isn't registered (analyze-only build), returns
|
|
// an empty list rather than 404 so the page can render its empty state.
|
|
|
|
type processEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Entry string `json:"entry"`
|
|
Steps int `json:"steps"`
|
|
Files int `json:"files"`
|
|
Repos int `json:"repos"`
|
|
Score int `json:"score"`
|
|
Risk string `json:"risk"` // ok | warn | risk
|
|
Crosses []string `json:"crosses"` // repo prefixes this flow touches
|
|
Category string `json:"category"` // product | tests | internal
|
|
}
|
|
|
|
// rawProcessSummary mirrors the MCP get_processes list response. Files
|
|
// and Steps are intentionally omitted — the list now carries precomputed
|
|
// step_count / file_count / repo_prefixes so the dashboard shape doesn't
|
|
// require a second call per process.
|
|
type rawProcessSummary struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
EntryPoint string `json:"entry_point"`
|
|
StepCount int `json:"step_count"`
|
|
FileCount int `json:"file_count"`
|
|
Score float64 `json:"score"`
|
|
RepoPrefixes []string `json:"repo_prefixes"`
|
|
}
|
|
|
|
func processEntryFromRaw(p rawProcessSummary) processEntry {
|
|
risk := "ok"
|
|
switch {
|
|
case p.Score > 1000:
|
|
risk = "risk"
|
|
case p.Score > 500:
|
|
risk = "warn"
|
|
}
|
|
crosses := p.RepoPrefixes
|
|
if crosses == nil {
|
|
crosses = []string{}
|
|
}
|
|
return processEntry{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Entry: p.EntryPoint,
|
|
Steps: p.StepCount,
|
|
Files: p.FileCount,
|
|
Repos: len(crosses),
|
|
Score: int(p.Score),
|
|
Risk: risk,
|
|
Crosses: crosses,
|
|
Category: categorizeProcess(p.EntryPoint),
|
|
}
|
|
}
|
|
|
|
// categorizeProcess buckets a flow by its entry point path so the
|
|
// Investigations UI can tab between production code, test entry points,
|
|
// and Go `internal/` package flows.
|
|
func categorizeProcess(entry string) string {
|
|
// Split "<repo>/<path>::<sym>" into the path half.
|
|
path, sym, hasSym := strings.Cut(entry, "::")
|
|
if hasSym {
|
|
if strings.HasPrefix(sym, "Test") || strings.HasPrefix(sym, "Benchmark") || strings.HasPrefix(sym, "Example") || strings.HasPrefix(sym, "Fuzz") {
|
|
return "tests"
|
|
}
|
|
}
|
|
lower := strings.ToLower(path)
|
|
if strings.HasSuffix(lower, "_test.go") || strings.Contains(lower, "_test.") {
|
|
return "tests"
|
|
}
|
|
if strings.Contains(path, "/internal/") || strings.HasPrefix(path, "internal/") {
|
|
return "internal"
|
|
}
|
|
return "product"
|
|
}
|
|
|
|
func (h *Handler) handleProcesses(w http.ResponseWriter, r *http.Request) {
|
|
raw, err := h.CallToolStrict(r.Context(), "get_processes", map[string]any{})
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if raw == "" {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"processes": []processEntry{}})
|
|
return
|
|
}
|
|
type wrap struct {
|
|
Processes []rawProcessSummary `json:"processes"`
|
|
}
|
|
var w0 wrap
|
|
if err := json.Unmarshal([]byte(raw), &w0); err != nil {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"processes": []processEntry{}})
|
|
return
|
|
}
|
|
out := make([]processEntry, 0, len(w0.Processes))
|
|
for _, p := range w0.Processes {
|
|
out = append(out, processEntryFromRaw(p))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
|
WriteJSON(w, http.StatusOK, map[string]any{"processes": out})
|
|
}
|
|
|
|
// --- /v1/contracts ---
|
|
//
|
|
// Flattens the contracts MCP tool's by_repo grouping into a single list
|
|
// keyed by canonical contract ID. The UI shows kind / producer /
|
|
// consumers / breaking flag; we fold provider+consumer rows into a
|
|
// single entry per contract ID so users see one row per route, not two.
|
|
|
|
type contractEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // REST | EVENT | URL | ENV | DEP (coarse, for badge)
|
|
Type string `json:"type"` // http | grpc | graphql | topic | ws | env | openapi | dependency
|
|
Scope string `json:"scope"` // own | external
|
|
Producer string `json:"producer"`
|
|
Consumers []string `json:"consumers"`
|
|
Version string `json:"version"`
|
|
Breaking bool `json:"breaking"`
|
|
Callers int `json:"callers"`
|
|
Last string `json:"last"`
|
|
Locations []contractLocation `json:"locations"`
|
|
|
|
// IsTestOnly is true when every location of this contract was
|
|
// extracted from a synthetic test/bench fixture (testdata/,
|
|
// bench/fixtures/, __fixtures__/). The UI uses this as a single
|
|
// boolean to hide synthetic rows from the production view by
|
|
// default, while still allowing a "show tests" toggle so drift
|
|
// checks (test pinned to a stale provider contract) stay visible.
|
|
// Mixed contracts — at least one production location plus one
|
|
// test location — keep IsTestOnly=false so they remain in the
|
|
// production view; the per-location Meta carries the granular
|
|
// is_test flag for callers that want to render badges per row.
|
|
IsTestOnly bool `json:"is_test_only,omitempty"`
|
|
|
|
// Schema-shape fields promoted from the primary location's meta so
|
|
// the UI can render a structured schema card instead of the raw
|
|
// per-location JSON. Populated from the provider when present,
|
|
// otherwise from the first consumer.
|
|
Schema *contractSchema `json:"schema,omitempty"`
|
|
// ProviderSchema / ConsumerSchema expose each side's meta
|
|
// independently so the UI can render side-by-side comparison.
|
|
// Both are nil when no location of that role has schema info.
|
|
ProviderSchema *contractSchema `json:"provider_schema,omitempty"`
|
|
ConsumerSchema *contractSchema `json:"consumer_schema,omitempty"`
|
|
}
|
|
|
|
// contractSchema summarises the request/response shape of a contract
|
|
// for UI consumption. All fields are optional and reflect whatever
|
|
// the extractor and post-pass could pin down. `Source` is one of
|
|
// "extracted" | "partial" | "none".
|
|
type contractSchema struct {
|
|
RequestType string `json:"request_type,omitempty"`
|
|
ResponseType string `json:"response_type,omitempty"`
|
|
RequestExpr string `json:"request_expr,omitempty"`
|
|
ResponseExpr string `json:"response_expr,omitempty"`
|
|
RequestStream bool `json:"request_stream,omitempty"`
|
|
ResponseStream bool `json:"response_stream,omitempty"`
|
|
// ResponseRepeated is true when the response value's declared
|
|
// type was a slice (e.g. `[]Foo` from `make([]Foo, …)`). The UI
|
|
// renders the response as `[Foo]` instead of `Foo`.
|
|
ResponseRepeated bool `json:"response_repeated,omitempty"`
|
|
// ResponseShape and RequestShape are the snapshotted struct
|
|
// definitions of ResponseType / RequestType — fields, JSON tags,
|
|
// types — so the UI can render the body as an actual JSON
|
|
// object instead of a bare type-symbol-ID. Empty when the type
|
|
// isn't a graph-known struct (primitives, external types).
|
|
ResponseShape map[string]any `json:"response_shape,omitempty"`
|
|
RequestShape map[string]any `json:"request_shape,omitempty"`
|
|
// ResponseEnvelope is the structured form of an inline map
|
|
// response like `map[string]any{"files": out}`. When present,
|
|
// the UI prefers it over ResponseExpr so the schema view shows
|
|
// the JSON shape (a list of {name, type, expr} fields) instead
|
|
// of the raw helper-call text.
|
|
ResponseEnvelope []envelopeFieldDTO `json:"response_envelope,omitempty"`
|
|
PathParams []string `json:"path_params,omitempty"`
|
|
// PathParamNames preserves the developer-written parameter names
|
|
// in the same positional order as PathParams. Contract IDs use
|
|
// positional p1/p2/... so cross-repo matching is naming-agnostic;
|
|
// this field surfaces the actual source-side names (e.g. ["id"])
|
|
// for display, drift detection, and OpenAPI export.
|
|
PathParamNames []string `json:"path_param_names,omitempty"`
|
|
QueryParams []string `json:"query_params,omitempty"`
|
|
StatusCodes []int `json:"status_codes,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
// envelopeFieldDTO mirrors one entry of contracts.envelopeField on the
|
|
// HTTP wire. Type is best-effort and may be empty when the source
|
|
// expression couldn't be traced to a typed binding; Expr is always
|
|
// the trimmed source expression that fed the JSON key. Shape is the
|
|
// snapshotted struct definition for Type — fields, JSON tags, etc. —
|
|
// so the UI can render the response as an actual JSON object instead
|
|
// of a bare type-symbol-ID. Empty when the type couldn't be expanded
|
|
// (external types, untyped expressions).
|
|
type envelopeFieldDTO struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type,omitempty"`
|
|
Expr string `json:"expr,omitempty"`
|
|
// Repeated is true when the originally-declared type was a slice
|
|
// (e.g. `[]Repo` or `make([]string, …)`). The UI renders the field
|
|
// as an array — `repos: [Repo]` instead of `repos: Repo`.
|
|
Repeated bool `json:"repeated,omitempty"`
|
|
Shape map[string]any `json:"shape,omitempty"`
|
|
}
|
|
|
|
// contractLocation is a single on-disk occurrence of a contract —
|
|
// either a provider handler or a consumer call site. The UI uses this
|
|
// to expand a contract row into a jump-list of file:line entries and
|
|
// to resolve symbol IDs back to source via /v1/tools/get_symbol_source.
|
|
type contractLocation struct {
|
|
Role string `json:"role"` // provider | consumer
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
SymbolID string `json:"symbol_id"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"line"`
|
|
Meta map[string]any `json:"meta,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) handleContracts(w http.ResponseWriter, r *http.Request) {
|
|
raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "list"})
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if raw == "" {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"contracts": []contractEntry{}})
|
|
return
|
|
}
|
|
type rawContract struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
SymbolID string `json:"symbol_id"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"line"`
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
Meta map[string]any `json:"meta"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
type wrap struct {
|
|
ByRepo map[string]struct {
|
|
Contracts map[string][]rawContract `json:"contracts"`
|
|
Total int `json:"total"`
|
|
} `json:"by_repo"`
|
|
}
|
|
var w0 wrap
|
|
if err := json.Unmarshal([]byte(raw), &w0); err != nil {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"contracts": []contractEntry{}})
|
|
return
|
|
}
|
|
merged := make(map[string]*contractEntry)
|
|
for _, group := range w0.ByRepo {
|
|
for kind, items := range group.Contracts {
|
|
for _, c := range items {
|
|
// Upstream NormalizeHTTPPath preserves parameter names,
|
|
// so a provider's "/v1/workspaces/{wid}/tags/{id}" and
|
|
// a consumer's "/v1/workspaces/{workspaceId}/tags/{id}"
|
|
// hash to different contract IDs for the same route.
|
|
// Re-canonicalize by positional params so they merge.
|
|
key := canonicalContractKey(c.ID)
|
|
e, ok := merged[key]
|
|
if !ok {
|
|
e = &contractEntry{
|
|
ID: key,
|
|
Name: key,
|
|
Kind: uiContractKind(kind),
|
|
Type: kind,
|
|
Consumers: []string{},
|
|
Locations: []contractLocation{},
|
|
}
|
|
merged[key] = e
|
|
}
|
|
if c.Role == "provider" && e.Producer == "" {
|
|
e.Producer = c.RepoPrefix
|
|
}
|
|
if c.Role == "consumer" && c.RepoPrefix != "" && !slices.Contains(e.Consumers, c.RepoPrefix) {
|
|
e.Consumers = append(e.Consumers, c.RepoPrefix)
|
|
}
|
|
e.Locations = append(e.Locations, contractLocation{
|
|
Role: c.Role,
|
|
RepoPrefix: c.RepoPrefix,
|
|
SymbolID: c.SymbolID,
|
|
FilePath: c.FilePath,
|
|
Line: c.Line,
|
|
Meta: c.Meta,
|
|
})
|
|
e.Callers++
|
|
}
|
|
}
|
|
}
|
|
out := make([]contractEntry, 0, len(merged))
|
|
for _, e := range merged {
|
|
e.Scope = contractScope(e.Type, e.Producer)
|
|
sort.SliceStable(e.Locations, func(i, j int) bool {
|
|
a, b := e.Locations[i], e.Locations[j]
|
|
if a.Role != b.Role {
|
|
return a.Role == "provider" // providers first
|
|
}
|
|
if a.RepoPrefix != b.RepoPrefix {
|
|
return a.RepoPrefix < b.RepoPrefix
|
|
}
|
|
if a.FilePath != b.FilePath {
|
|
return a.FilePath < b.FilePath
|
|
}
|
|
return a.Line < b.Line
|
|
})
|
|
e.Schema = promoteSchemaFromLocations(e.Locations)
|
|
e.ProviderSchema = promoteSchemaForRole(e.Locations, "provider")
|
|
e.ConsumerSchema = promoteSchemaForRole(e.Locations, "consumer")
|
|
e.IsTestOnly = locationsAllTagged(e.Locations, "is_test")
|
|
out = append(out, *e)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Producer != out[j].Producer {
|
|
return out[i].Producer < out[j].Producer
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
WriteJSON(w, http.StatusOK, map[string]any{"contracts": out})
|
|
}
|
|
|
|
// --- /v1/contracts/validate ---
|
|
//
|
|
// Passes through to the contracts MCP tool's `validate` action and
|
|
// returns its JSON payload unchanged: `{issues: [...], summary: {...}}`.
|
|
// The UI consumes this to badge contract rows with breaking-change
|
|
// counts and render a per-contract diff panel.
|
|
|
|
func (h *Handler) handleContractsValidate(w http.ResponseWriter, r *http.Request) {
|
|
raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "validate"})
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if raw == "" {
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"issues": []any{},
|
|
"summary": map[string]int{"total": 0, "breaking": 0, "warning": 0, "info": 0},
|
|
})
|
|
return
|
|
}
|
|
// The tool returns JSON we can relay verbatim — re-decoding to a
|
|
// Go map would lose the severity enum's string form. Only thing
|
|
// we do is verify it parses as JSON; malformed input falls back
|
|
// to an empty payload rather than propagating a server error.
|
|
var probe any
|
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"issues": []any{},
|
|
"summary": map[string]int{"total": 0, "breaking": 0, "warning": 0, "info": 0},
|
|
})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(raw))
|
|
}
|
|
|
|
func uiContractKind(raw string) string {
|
|
switch raw {
|
|
case "topic":
|
|
return "EVENT"
|
|
case "ws":
|
|
return "URL"
|
|
case "http", "grpc", "graphql", "openapi":
|
|
return "REST"
|
|
case "env":
|
|
return "ENV"
|
|
case "dependency":
|
|
return "DEP"
|
|
default:
|
|
return strings.ToUpper(raw)
|
|
}
|
|
}
|
|
|
|
// promoteSchemaForRole extracts a schema summary from every location
|
|
// matching `role` (provider | consumer). Multiple locations for the
|
|
// same role are merged additively — first non-empty value wins for
|
|
// type fields, lists union. Returns nil when no matching location has
|
|
// any schema info.
|
|
func promoteSchemaForRole(locs []contractLocation, role string) *contractSchema {
|
|
var primary *contractLocation
|
|
for i := range locs {
|
|
if locs[i].Role != role || locs[i].Meta == nil {
|
|
continue
|
|
}
|
|
primary = &locs[i]
|
|
break
|
|
}
|
|
if primary == nil {
|
|
return nil
|
|
}
|
|
s := &contractSchema{
|
|
RequestType: metaString(primary.Meta, "request_type"),
|
|
ResponseType: metaString(primary.Meta, "response_type"),
|
|
RequestExpr: metaString(primary.Meta, "request_expr"),
|
|
ResponseExpr: metaString(primary.Meta, "response_expr"),
|
|
ResponseRepeated: metaBool(primary.Meta, "response_repeated"),
|
|
ResponseShape: normaliseShape(primary.Meta["response_shape"]),
|
|
RequestShape: normaliseShape(primary.Meta["request_shape"]),
|
|
ResponseEnvelope: metaEnvelope(primary.Meta, "response_envelope"),
|
|
Source: metaString(primary.Meta, "schema_source"),
|
|
PathParams: metaStrings(primary.Meta, "path_params"),
|
|
PathParamNames: metaStrings(primary.Meta, "path_param_names"),
|
|
QueryParams: metaStrings(primary.Meta, "query_params"),
|
|
StatusCodes: metaInts(primary.Meta, "status_codes"),
|
|
}
|
|
if v, _ := primary.Meta["request_stream"].(bool); v {
|
|
s.RequestStream = true
|
|
}
|
|
if v, _ := primary.Meta["response_stream"].(bool); v {
|
|
s.ResponseStream = true
|
|
}
|
|
// Merge secondary same-role locations (multi-consumer case).
|
|
for i := range locs {
|
|
l := &locs[i]
|
|
if l == primary || l.Role != role || l.Meta == nil {
|
|
continue
|
|
}
|
|
if s.RequestType == "" {
|
|
s.RequestType = metaString(l.Meta, "request_type")
|
|
}
|
|
if s.ResponseType == "" {
|
|
s.ResponseType = metaString(l.Meta, "response_type")
|
|
}
|
|
if len(s.StatusCodes) == 0 {
|
|
s.StatusCodes = metaInts(l.Meta, "status_codes")
|
|
}
|
|
if len(s.QueryParams) == 0 {
|
|
s.QueryParams = metaStrings(l.Meta, "query_params")
|
|
}
|
|
if len(s.ResponseEnvelope) == 0 {
|
|
s.ResponseEnvelope = metaEnvelope(l.Meta, "response_envelope")
|
|
}
|
|
}
|
|
if schemaIsEmpty(s) {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// locationsAllTagged reports whether every location carries the named
|
|
// boolean meta flag (e.g. "is_test"). Empty location list returns
|
|
// false — there's nothing to be uniformly tagged. Used to roll up the
|
|
// per-location is_test stamp into a single is_test_only contractEntry
|
|
// flag.
|
|
func locationsAllTagged(locs []contractLocation, key string) bool {
|
|
if len(locs) == 0 {
|
|
return false
|
|
}
|
|
for _, l := range locs {
|
|
if v, _ := l.Meta[key].(bool); !v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// promoteSchemaFromLocations folds schema-shape meta from the primary
|
|
// location (provider first, consumer fallback) into a flat
|
|
// contractSchema for UI rendering. Returns nil when nothing useful
|
|
// was pinned down so the wire shape stays compact.
|
|
func promoteSchemaFromLocations(locs []contractLocation) *contractSchema {
|
|
var primary contractLocation
|
|
found := false
|
|
for _, l := range locs {
|
|
if l.Role == "provider" {
|
|
primary = l
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found && len(locs) > 0 {
|
|
primary = locs[0]
|
|
found = true
|
|
}
|
|
if !found || primary.Meta == nil {
|
|
return nil
|
|
}
|
|
s := &contractSchema{
|
|
RequestType: metaString(primary.Meta, "request_type"),
|
|
ResponseType: metaString(primary.Meta, "response_type"),
|
|
RequestExpr: metaString(primary.Meta, "request_expr"),
|
|
ResponseExpr: metaString(primary.Meta, "response_expr"),
|
|
ResponseRepeated: metaBool(primary.Meta, "response_repeated"),
|
|
ResponseShape: normaliseShape(primary.Meta["response_shape"]),
|
|
RequestShape: normaliseShape(primary.Meta["request_shape"]),
|
|
ResponseEnvelope: metaEnvelope(primary.Meta, "response_envelope"),
|
|
Source: metaString(primary.Meta, "schema_source"),
|
|
PathParams: metaStrings(primary.Meta, "path_params"),
|
|
PathParamNames: metaStrings(primary.Meta, "path_param_names"),
|
|
QueryParams: metaStrings(primary.Meta, "query_params"),
|
|
StatusCodes: metaInts(primary.Meta, "status_codes"),
|
|
}
|
|
if v, _ := primary.Meta["request_stream"].(bool); v {
|
|
s.RequestStream = true
|
|
}
|
|
if v, _ := primary.Meta["response_stream"].(bool); v {
|
|
s.ResponseStream = true
|
|
}
|
|
// If a provider had nothing but a consumer also has meta, try
|
|
// filling gaps from it — useful for contracts where the consumer
|
|
// (e.g. a generated SDK) carries types the provider didn't
|
|
// annotate.
|
|
for _, l := range locs {
|
|
if l.Role == primary.Role || l.Meta == nil {
|
|
continue
|
|
}
|
|
if s.RequestType == "" {
|
|
s.RequestType = metaString(l.Meta, "request_type")
|
|
}
|
|
if s.ResponseType == "" {
|
|
s.ResponseType = metaString(l.Meta, "response_type")
|
|
}
|
|
if len(s.StatusCodes) == 0 {
|
|
s.StatusCodes = metaInts(l.Meta, "status_codes")
|
|
}
|
|
if len(s.QueryParams) == 0 {
|
|
s.QueryParams = metaStrings(l.Meta, "query_params")
|
|
}
|
|
if len(s.ResponseEnvelope) == 0 {
|
|
s.ResponseEnvelope = metaEnvelope(l.Meta, "response_envelope")
|
|
}
|
|
}
|
|
if schemaIsEmpty(s) {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func schemaIsEmpty(s *contractSchema) bool {
|
|
if s == nil {
|
|
return true
|
|
}
|
|
return s.RequestType == "" && s.ResponseType == "" && s.RequestExpr == "" && s.ResponseExpr == "" &&
|
|
len(s.ResponseEnvelope) == 0 &&
|
|
len(s.ResponseShape) == 0 && len(s.RequestShape) == 0 &&
|
|
len(s.PathParams) == 0 && len(s.QueryParams) == 0 && len(s.StatusCodes) == 0 &&
|
|
!s.RequestStream && !s.ResponseStream && !s.ResponseRepeated
|
|
}
|
|
|
|
// metaEnvelope decodes the upstream "response_envelope" meta entry —
|
|
// emitted by the contract enrichers as a []map[string]any with name /
|
|
// type / expr keys per row. The intermediate JSON pass between
|
|
// daemon and dashboard turns the slice into []any of map[string]any,
|
|
// so we accept both shapes.
|
|
func metaEnvelope(m map[string]any, key string) []envelopeFieldDTO {
|
|
raw := m[key]
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
var rows []map[string]any
|
|
switch v := raw.(type) {
|
|
case []map[string]any:
|
|
rows = v
|
|
case []any:
|
|
rows = make([]map[string]any, 0, len(v))
|
|
for _, x := range v {
|
|
if row, ok := x.(map[string]any); ok {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]envelopeFieldDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
f := envelopeFieldDTO{}
|
|
if s, ok := row["name"].(string); ok {
|
|
f.Name = s
|
|
}
|
|
if s, ok := row["type"].(string); ok {
|
|
f.Type = s
|
|
}
|
|
if s, ok := row["expr"].(string); ok {
|
|
f.Expr = s
|
|
}
|
|
if v, ok := row["repeated"].(bool); ok {
|
|
f.Repeated = v
|
|
}
|
|
// Shape arrives either as a *contracts.Shape (in-process call)
|
|
// or as map[string]any (post-JSON-roundtrip on the wire). The
|
|
// dashboard re-emits it as JSON either way, so we normalise to
|
|
// map[string]any here.
|
|
f.Shape = normaliseShape(row["shape"])
|
|
if f.Name != "" {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normaliseShape coerces whatever the contracts MCP tool emitted for
|
|
// a "shape" Meta value into a plain map[string]any so the dashboard
|
|
// can re-emit it as JSON. Already-decoded JSON arrives as
|
|
// map[string]any; gob-decoded paths produce *contracts.Shape (or its
|
|
// equivalent struct) which we marshal+unmarshal once.
|
|
func normaliseShape(raw any) map[string]any {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
if m, ok := raw.(map[string]any); ok {
|
|
return m
|
|
}
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out map[string]any
|
|
if json.Unmarshal(b, &out) != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func metaString(m map[string]any, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func metaBool(m map[string]any, key string) bool {
|
|
if v, ok := m[key].(bool); ok {
|
|
return v
|
|
}
|
|
return false
|
|
}
|
|
|
|
func metaStrings(m map[string]any, key string) []string {
|
|
switch v := m[key].(type) {
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, x := range v {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func metaInts(m map[string]any, key string) []int {
|
|
switch v := m[key].(type) {
|
|
case []int:
|
|
return v
|
|
case []any:
|
|
out := make([]int, 0, len(v))
|
|
for _, x := range v {
|
|
switch n := x.(type) {
|
|
case int:
|
|
out = append(out, n)
|
|
case float64:
|
|
out = append(out, int(n))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// contractScope decides whether a merged contract row represents something
|
|
// defined in the active project ("own") or something it only consumes
|
|
// ("external"). Go-module / package dependency contracts are always
|
|
// external by construction; for the rest, a row is own iff we found a
|
|
// provider inside the active-project repo set.
|
|
func contractScope(rawType, producer string) string {
|
|
if rawType == "dependency" {
|
|
return "external"
|
|
}
|
|
if producer == "" {
|
|
return "external"
|
|
}
|
|
return "own"
|
|
}
|
|
|
|
// canonicalContractIDParam matches any {name} path-parameter placeholder
|
|
// in a contract ID. We rewrite the name to a positional {p1}, {p2}, …
|
|
// so the same HTTP route matches regardless of whether the provider
|
|
// called the param {id} and the consumer called it {entityId}.
|
|
var canonicalContractIDParam = regexp.MustCompile(`\{[^}]+\}`)
|
|
|
|
// canonicalContractKey returns a merge-stable key for a raw contract
|
|
// ID. Only IDs whose first segment is "http" are rewritten; other
|
|
// contract types (grpc, topic, env, dependency) already have structural
|
|
// IDs that don't embed param names.
|
|
func canonicalContractKey(id string) string {
|
|
if !strings.HasPrefix(id, "http::") {
|
|
return id
|
|
}
|
|
i := 0
|
|
return canonicalContractIDParam.ReplaceAllStringFunc(id, func(string) string {
|
|
i++
|
|
return fmt.Sprintf("{p%d}", i)
|
|
})
|
|
}
|
|
|
|
// --- /v1/communities ---
|
|
//
|
|
// Returns the community detection result reshaped for the dashboard
|
|
// communities card. The MCP get_communities list summary already
|
|
// carries the majority repo prefix and file count so we don't need a
|
|
// second call per community.
|
|
|
|
type communityEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Repo string `json:"repo"`
|
|
Symbols int `json:"symbols"`
|
|
Files int `json:"files"`
|
|
Cohesion float64 `json:"cohesion"`
|
|
// ParentID points at the phase-2 super-community this leaf
|
|
// belongs to. Sibling clusters under the same ParentID get
|
|
// grouped under a shared header in the dashboard UI.
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) handleCommunities(w http.ResponseWriter, r *http.Request) {
|
|
raw, err := h.CallToolStrict(r.Context(), "get_communities", map[string]any{})
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if raw == "" {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"communities": []communityEntry{}, "modularity": 0.0})
|
|
return
|
|
}
|
|
type rawComm struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Size int `json:"size"`
|
|
FileCount int `json:"file_count"`
|
|
Cohesion float64 `json:"cohesion"`
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
}
|
|
type wrap struct {
|
|
Communities []rawComm `json:"communities"`
|
|
Modularity float64 `json:"modularity"`
|
|
}
|
|
var w0 wrap
|
|
if err := json.Unmarshal([]byte(raw), &w0); err != nil {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"communities": []communityEntry{}, "modularity": 0.0})
|
|
return
|
|
}
|
|
out := make([]communityEntry, 0, len(w0.Communities))
|
|
for _, c := range w0.Communities {
|
|
out = append(out, communityEntry{
|
|
ID: c.ID,
|
|
Name: c.Label,
|
|
Repo: c.RepoPrefix,
|
|
Symbols: c.Size,
|
|
Files: c.FileCount,
|
|
Cohesion: c.Cohesion,
|
|
ParentID: c.ParentID,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Symbols > out[j].Symbols })
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"communities": out,
|
|
"modularity": w0.Modularity,
|
|
})
|
|
}
|
|
|
|
// --- /v1/guards ---
|
|
//
|
|
// Wraps check_guards into the table shape used by the Guards page. The
|
|
// MCP tool returns per-rule violations; we group by rule and report a
|
|
// status (ok | warn | violated) with the hit count.
|
|
|
|
type guardEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
Hits int `json:"hits"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
// handleGuards lists guard rules from configuration. Status is "ok" by
|
|
// default — rules don't have a runtime "violated" state until evaluated
|
|
// against a change set, which is the job of the check_guards MCP tool
|
|
// (callable via /v1/tools/check_guards with an `ids` argument). The
|
|
// page shows what's configured; the IDE / agent gets violations
|
|
// per-change.
|
|
func (h *Handler) handleGuards(w http.ResponseWriter, _ *http.Request) {
|
|
out := make([]guardEntry, 0)
|
|
seen := make(map[string]bool)
|
|
add := func(rules []struct {
|
|
Name, Kind, Source, Target string
|
|
}) {
|
|
for i, r := range rules {
|
|
key := r.Name + "::" + r.Source + "::" + r.Target
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, guardEntry{
|
|
ID: fmt.Sprintf("%s-%d", r.Name, i),
|
|
Name: r.Name,
|
|
Kind: r.Kind,
|
|
Status: "ok",
|
|
Hits: 0,
|
|
Scope: r.Source + " → " + r.Target,
|
|
})
|
|
}
|
|
}
|
|
if h.configManager != nil {
|
|
// Workspace overrides per active repo + global defaults at "".
|
|
repos := append([]string{""}, repoNames(h.configManager.ActiveRepos())...)
|
|
for _, name := range repos {
|
|
rules := h.configManager.EffectiveGuardRules(name)
|
|
compact := make([]struct {
|
|
Name, Kind, Source, Target string
|
|
}, 0, len(rules))
|
|
for _, r := range rules {
|
|
compact = append(compact, struct {
|
|
Name, Kind, Source, Target string
|
|
}{r.Name, r.Kind, r.Source, r.Target})
|
|
}
|
|
add(compact)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
WriteJSON(w, http.StatusOK, map[string]any{"guards": out})
|
|
}
|
|
|
|
func repoNames(repos []config.RepoEntry) []string {
|
|
out := make([]string, 0, len(repos))
|
|
for _, r := range repos {
|
|
if r.Name != "" {
|
|
out = append(out, r.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- /v1/caveats ---
|
|
|
|
type caveatEntry struct {
|
|
ID string `json:"id"`
|
|
Severity string `json:"severity"` // risk | hot | cycle | unowned | deprecated | boundary
|
|
Symbol string `json:"symbol"`
|
|
Title string `json:"title"`
|
|
Desc string `json:"desc"`
|
|
Owner string `json:"owner"`
|
|
Age string `json:"age"`
|
|
// Graph-enriched fields. Populated in handleCaveats after parsing,
|
|
// by looking up the symbol in the graph — all optional because cycle
|
|
// entries and synthetic caveats may have no resolvable node.
|
|
FilePath string `json:"file_path,omitempty"`
|
|
RepoPrefix string `json:"repo_prefix,omitempty"`
|
|
Kind string `json:"kind,omitempty"`
|
|
FanIn int `json:"fan_in,omitempty"`
|
|
ExternalCallers int `json:"external_callers,omitempty"`
|
|
CallerRepos []string `json:"caller_repos,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) handleCaveats(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
out := make([]caveatEntry, 0, 32)
|
|
|
|
// check_guards is intentionally NOT called here — the MCP tool
|
|
// requires a `changed symbol IDs` argument and only returns
|
|
// violations against that change set. The Caveats page is supposed
|
|
// to surface persistent landmines, not what would fire if a
|
|
// specific commit ran. Boundary/ownership violations come from the
|
|
// /v1/guards endpoint instead.
|
|
for _, kind := range []string{"hotspots", "dead_code", "cycles"} {
|
|
raw, err := h.CallToolStrict(ctx, "analyze", map[string]any{"kind": kind, "limit": 20})
|
|
if err != nil {
|
|
h.logger.Warn("caveats: analyze sub-call failed; section will be empty",
|
|
zap.String("kind", kind), zap.Error(err))
|
|
continue
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
switch kind {
|
|
case "hotspots":
|
|
out = append(out, parseHotspots(raw)...)
|
|
case "dead_code":
|
|
out = append(out, parseDeadCode(raw)...)
|
|
case "cycles":
|
|
out = append(out, parseCycles(raw)...)
|
|
}
|
|
}
|
|
|
|
severityRank := map[string]int{
|
|
"risk": 0,
|
|
"hot": 1,
|
|
"cycle": 2,
|
|
"boundary": 3,
|
|
"unowned": 4,
|
|
"deprecated": 5,
|
|
}
|
|
sortByRank(out, severityRank)
|
|
enrichCaveats(h.Graph(), out)
|
|
WriteJSON(w, http.StatusOK, map[string]any{"caveats": out})
|
|
}
|
|
|
|
// enrichCaveats fills in file_path, repo_prefix, kind, fan_in, and
|
|
// cross-repo caller counts by looking each caveat's symbol up in the
|
|
// graph. Entries with an unresolvable symbol (e.g. cycle placeholders
|
|
// or stale IDs from a prior index) are left untouched so the caller can
|
|
// detect the gap instead of rendering zeros that look like real data.
|
|
func enrichCaveats(g graph.Store, caveats []caveatEntry) {
|
|
if g == nil {
|
|
return
|
|
}
|
|
for i := range caveats {
|
|
sym := caveats[i].Symbol
|
|
if sym == "" {
|
|
continue
|
|
}
|
|
node := g.GetNode(sym)
|
|
if node == nil {
|
|
continue
|
|
}
|
|
caveats[i].FilePath = node.FilePath
|
|
caveats[i].RepoPrefix = node.RepoPrefix
|
|
caveats[i].Kind = string(node.Kind)
|
|
|
|
inEdges := g.GetInEdges(sym)
|
|
repoSet := make(map[string]struct{}, len(inEdges))
|
|
var fanIn, external int
|
|
for _, e := range inEdges {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
fanIn++
|
|
caller := g.GetNode(e.From)
|
|
if caller == nil {
|
|
continue
|
|
}
|
|
if caller.RepoPrefix != "" && caller.RepoPrefix != node.RepoPrefix {
|
|
external++
|
|
repoSet[caller.RepoPrefix] = struct{}{}
|
|
}
|
|
}
|
|
caveats[i].FanIn = fanIn
|
|
caveats[i].ExternalCallers = external
|
|
if len(repoSet) > 0 {
|
|
repos := make([]string, 0, len(repoSet))
|
|
for r := range repoSet {
|
|
repos = append(repos, r)
|
|
}
|
|
sort.Strings(repos)
|
|
caveats[i].CallerRepos = repos
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortByRank(in []caveatEntry, rank map[string]int) {
|
|
for i := 1; i < len(in); i++ {
|
|
j := i
|
|
for j > 0 && rank[in[j-1].Severity] > rank[in[j].Severity] {
|
|
in[j-1], in[j] = in[j], in[j-1]
|
|
j--
|
|
}
|
|
}
|
|
}
|
|
|
|
// All four parsers below produce IDs that combine a per-parser prefix
|
|
// with the entry index. The index is essential — the underlying tools
|
|
// can return entries with empty IDs (e.g. cycles, where the natural ID
|
|
// is the path), and the React UI uses the ID as a list key. Without
|
|
// the index suffix, multiple empty-ID entries collapse to the same key
|
|
// and React warns about duplicates.
|
|
|
|
func parseHotspots(raw string) []caveatEntry {
|
|
// Mirrors analysis.HotspotEntry.
|
|
type hotspot struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"start_line"`
|
|
FanIn int `json:"fan_in"`
|
|
}
|
|
type wrap struct {
|
|
Hotspots []hotspot `json:"hotspots"`
|
|
}
|
|
var w wrap
|
|
if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.Hotspots) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]caveatEntry, 0, len(w.Hotspots))
|
|
for i, h := range w.Hotspots {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
name := h.Name
|
|
if name == "" {
|
|
name = h.ID
|
|
}
|
|
out = append(out, caveatEntry{
|
|
ID: fmt.Sprintf("hs-%d-%s", i, h.ID),
|
|
Severity: "hot",
|
|
Symbol: h.ID,
|
|
Title: "Hot path · " + name,
|
|
Desc: fmt.Sprintf("Fan-in %d — touched by many call sites.", h.FanIn),
|
|
Age: "ongoing",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseDeadCode(raw string) []caveatEntry {
|
|
// Mirrors analysis.DeadCodeEntry.
|
|
type entry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
}
|
|
type wrap struct {
|
|
DeadCode []entry `json:"dead_code"`
|
|
}
|
|
var w wrap
|
|
if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.DeadCode) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]caveatEntry, 0, len(w.DeadCode))
|
|
for i, e := range w.DeadCode {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
name := e.Name
|
|
if name == "" {
|
|
name = e.ID
|
|
}
|
|
out = append(out, caveatEntry{
|
|
ID: fmt.Sprintf("dc-%d-%s", i, e.ID),
|
|
Severity: "deprecated",
|
|
Symbol: e.ID,
|
|
Title: "Likely unreachable · " + name,
|
|
Desc: "No incoming references in the indexed graph.",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseCycles(raw string) []caveatEntry {
|
|
// Mirrors analysis.Cycle: { path[], kind, severity }. Earlier
|
|
// versions of this parser looked for `id` and `members`, which the
|
|
// real type doesn't have — every cycle ended up with an empty ID,
|
|
// and the dashboard's React keys collided. Now the entry index is
|
|
// part of the ID so duplicate or empty paths still render distinctly.
|
|
type cycle struct {
|
|
Path []string `json:"path"`
|
|
Kind string `json:"kind"`
|
|
Severity int `json:"severity"`
|
|
}
|
|
type wrap struct {
|
|
Cycles []cycle `json:"cycles"`
|
|
}
|
|
var w wrap
|
|
if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.Cycles) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]caveatEntry, 0, len(w.Cycles))
|
|
for i, c := range w.Cycles {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
title := "Circular dependency"
|
|
symbol := ""
|
|
if len(c.Path) > 0 {
|
|
symbol = c.Path[0]
|
|
title = "Cycle: " + symbol
|
|
}
|
|
desc := fmt.Sprintf("%d symbols form a %s cycle.", len(c.Path), nonEmpty(c.Kind, "dependency"))
|
|
out = append(out, caveatEntry{
|
|
ID: fmt.Sprintf("cy-%d-%s", i, symbol),
|
|
Severity: "cycle",
|
|
Symbol: symbol,
|
|
Title: title,
|
|
Desc: desc,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nonEmpty(s, fallback string) string {
|
|
if s == "" {
|
|
return fallback
|
|
}
|
|
return s
|
|
}
|
|
|
|
// --- /v1/dashboard ---
|
|
//
|
|
// Bundles every datum the dashboard hero card needs into one round-trip:
|
|
// totals, kinds + languages (as ordered arrays so the UI doesn't sort),
|
|
// repo cards, recent activity, and aggregated caveats. Designed to be
|
|
// cheap (one pass through stats + cached analyze tool results).
|
|
|
|
type dashboardSnapshot struct {
|
|
Stats struct {
|
|
TotalNodes int `json:"total_nodes"`
|
|
TotalEdges int `json:"total_edges"`
|
|
Repos int `json:"repos"`
|
|
Caveats int `json:"caveats"`
|
|
Version string `json:"version"`
|
|
} `json:"stats"`
|
|
Kinds []kvEntry `json:"kinds"`
|
|
Languages []kvEntry `json:"languages"`
|
|
Repos []repoEntry `json:"repos"`
|
|
Activity []indexer.GraphChangeEvent `json:"activity"`
|
|
Caveats []caveatEntry `json:"caveats"`
|
|
Processes []processEntry `json:"processes"`
|
|
}
|
|
|
|
type kvEntry struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func mapToOrderedKV(m map[string]int) []kvEntry {
|
|
out := make([]kvEntry, 0, len(m))
|
|
for k, v := range m {
|
|
out = append(out, kvEntry{Name: k, Count: v})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
stats := h.graph.Stats()
|
|
snap := dashboardSnapshot{}
|
|
snap.Stats.TotalNodes = stats.TotalNodes
|
|
snap.Stats.TotalEdges = stats.TotalEdges
|
|
snap.Stats.Version = h.version
|
|
snap.Kinds = mapToOrderedKV(stats.ByKind)
|
|
snap.Languages = mapToOrderedKV(stats.ByLanguage)
|
|
snap.Repos = reposFromGraph(h.graph)
|
|
snap.Stats.Repos = len(snap.Repos)
|
|
|
|
if h.activity != nil {
|
|
snap.Activity = h.activity.snapshot(20)
|
|
} else {
|
|
snap.Activity = []indexer.GraphChangeEvent{}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
// Reuse the caveats aggregator so the count and the inline preview
|
|
// both come from the same data — no chance of the dashboard's
|
|
// number disagreeing with the Caveats page on first load.
|
|
cavs := make([]caveatEntry, 0, 32)
|
|
for _, kind := range []string{"hotspots", "dead_code", "cycles"} {
|
|
raw, err := h.CallToolStrict(ctx, "analyze", map[string]any{"kind": kind, "limit": 20})
|
|
if err != nil {
|
|
h.logger.Warn("dashboard: analyze sub-call failed; caveats section will be partial",
|
|
zap.String("kind", kind), zap.Error(err))
|
|
continue
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
switch kind {
|
|
case "hotspots":
|
|
cavs = append(cavs, parseHotspots(raw)...)
|
|
case "dead_code":
|
|
cavs = append(cavs, parseDeadCode(raw)...)
|
|
case "cycles":
|
|
cavs = append(cavs, parseCycles(raw)...)
|
|
}
|
|
}
|
|
snap.Caveats = cavs
|
|
snap.Stats.Caveats = len(cavs)
|
|
|
|
// Top processes for the inline preview. The full list is on the
|
|
// Processes page; here we cap at 6 so the dashboard stays compact.
|
|
if raw, err := h.CallToolStrict(ctx, "get_processes", map[string]any{}); err != nil {
|
|
h.logger.Warn("dashboard: get_processes failed; processes section will be empty",
|
|
zap.Error(err))
|
|
} else if raw != "" {
|
|
type wrap struct {
|
|
Processes []rawProcessSummary `json:"processes"`
|
|
}
|
|
var w0 wrap
|
|
if json.Unmarshal([]byte(raw), &w0) == nil {
|
|
procs := make([]processEntry, 0, len(w0.Processes))
|
|
for _, p := range w0.Processes {
|
|
procs = append(procs, processEntryFromRaw(p))
|
|
}
|
|
sort.Slice(procs, func(i, j int) bool { return procs[i].Score > procs[j].Score })
|
|
if len(procs) > 6 {
|
|
procs = procs[:6]
|
|
}
|
|
snap.Processes = procs
|
|
}
|
|
}
|
|
if snap.Processes == nil {
|
|
snap.Processes = []processEntry{}
|
|
}
|
|
|
|
WriteJSON(w, http.StatusOK, snap)
|
|
}
|