Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

549 lines
18 KiB
Go

package mcp
import (
"context"
"maps"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
)
// registerArchitectureTool wires get_architecture — the single-shot
// "what does this repo look like" snapshot. Composes outline +
// processes + cross-repo edge rollup + contracts summary into one
// response so an agent can orient in one call instead of fanning
// out to graph_stats + get_communities + analyze cross_repo +
// contracts list + DiscoverProcesses.
func (s *Server) registerArchitectureTool() {
s.addTool(
mcp.NewTool("get_architecture",
mcp.WithDescription("Single-shot architectural snapshot: language mix, top communities, hotspots, entry points, discovered processes, cross-repo edge rollup, and contract counts. Composes the substrate `gortex://surprises` + `get_repo_outline` + `analyze cross_repo` + `contracts list` already expose into one structured response. Use at the start of an architecture review or onboarding session."),
mcp.WithNumber("top_communities", mcp.Description("Cap on number of communities returned (default: 5).")),
mcp.WithNumber("top_hotspots", mcp.Description("Cap on number of hotspots returned (default: 5).")),
mcp.WithNumber("top_processes", mcp.Description("Cap on number of discovered processes returned (default: 5).")),
mcp.WithNumber("top_entry_points", mcp.Description("Cap on entry-point list (default: 10).")),
mcp.WithString("path_prefix", mcp.Description("Restrict communities / hotspots / processes / entry points to those touching this file-path prefix.")),
mcp.WithString("resolution", mcp.Description("Add a hierarchical multi-resolution rollup of the graph at this tier — one of file, package, service, or system. The response gains a `hierarchy` block holding rollup nodes (one per group, with a leaf count) and weighted rollup edges (weight = the count of underlying leaf-level edges crossing the two groups) — the architecture at that tier, with no function-leaf nodes. Omit (or pass symbol) to skip the rollup.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleGetArchitecture,
)
}
// architectureProcess is the wire shape for a discovered process.
// Trimmed from analysis.Process — step lists can be hundreds of
// nodes and the agent rarely needs them inline.
type architectureProcess struct {
ID string `json:"id"`
Name string `json:"name"`
EntryPoint string `json:"entry_point"`
StepCount int `json:"step_count"`
Files []string `json:"files"`
Score float64 `json:"score"`
}
// crossRepoRow rolls up a single cross-repo edge instance into one
// row for the architecture response. The full edge dump goes through
// analyze cross_repo when an agent needs it.
type crossRepoRow struct {
Kind string `json:"kind"`
FromRepo string `json:"from_repo"`
ToRepo string `json:"to_repo"`
Count int `json:"count"`
}
func (s *Server) handleGetArchitecture(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
topCommunities := max(req.GetInt("top_communities", 5), 1)
topHotspots := max(req.GetInt("top_hotspots", 5), 1)
topProcesses := max(req.GetInt("top_processes", 5), 1)
topEntryPoints := max(req.GetInt("top_entry_points", 10), 1)
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
// scoped + inScope are only needed when the session is bound or
// the caller supplied a path-prefix narrowing. Otherwise every
// node is in scope and downstream membership tests are tautologies
// the helpers handle via nil inScope.
_, _, bound := s.sessionScope(ctx)
needScoped := bound || pathPrefix != ""
var scoped []*graph.Node
var inScope map[string]bool
var totalNodesScoped int
if needScoped {
scoped = s.scopedNodes(ctx)
inScope = make(map[string]bool, len(scoped))
for _, n := range scoped {
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
inScope[n.ID] = true
}
totalNodesScoped = len(inScope)
} else {
totalNodesScoped = s.graph.NodeCount()
}
// 1. Summary — language mix + node/edge counts.
summary := architectureSummary(scoped, inScope, totalNodesScoped, s.graph)
// 2. Communities — same shape as the outline tool, capped here.
communitiesSection := architectureCommunities(s.getCommunities(), inScope, topCommunities)
// 3. Hotspots — load-bearing symbols, scoped + capped.
hotspots := architectureHotspots(s.getHotspots(), inScope, topHotspots)
// 4. Entry points — functions with zero in-edges that have
// out-edges (called by no one, calls into the system). Sorted
// by out-degree so the most-impactful entry points surface first.
entries := architectureEntryPoints(inScope, s.graph, topEntryPoints)
// 5. Processes — analysis.DiscoverProcesses output, trimmed.
processes := architectureProcesses(s.getProcesses(), inScope, topProcesses)
// 6. Cross-repo edges — rollup by (from_repo, to_repo, kind) so
// the architecture view shows which repos talk to which without
// dumping every individual call site.
crossRepo := architectureCrossRepo(s.graph)
// 7. Contracts — count by type and role, plus a per-workspace
// rollup. The full contract list lives behind `contracts list`.
contractsSection := architectureContracts(s.contractRegistry)
out := map[string]any{
"summary": summary,
"communities": communitiesSection,
"hotspots": hotspots,
"entry_points": entries,
"processes": processes,
"cross_repo": crossRepo,
"contracts": contractsSection,
}
// 8. Hierarchy — optional multi-resolution rollup. When the caller
// asks for a resolution tier, collapse the leaf graph to that tier
// so the response carries the architecture at the requested
// granularity (file / package / service / system) with no
// function-leaf nodes. Computed on demand from the base graph.
if hierarchy, errMsg := architectureHierarchy(s.graph, s.getCommunities(), req.GetString("resolution", "")); errMsg != "" {
return mcp.NewToolResultError(errMsg), nil
} else if hierarchy != nil {
out["hierarchy"] = hierarchy
}
return s.respondJSONOrTOON(ctx, req, out)
}
// architectureHierarchy builds the optional multi-resolution rollup
// block for get_architecture. An empty resolution argument means the
// caller did not ask for a rollup — it returns (nil, ""). An
// unrecognised tier returns ("", message) so the handler can surface a
// clean error. Otherwise it rolls the base graph up to the requested
// tier via analysis.BuildHierarchy and returns the wire shape.
func architectureHierarchy(g graph.Store, cr *analysis.CommunityResult, resolution string) (map[string]any, string) {
resolution = strings.ToLower(strings.TrimSpace(resolution))
if resolution == "" {
return nil, ""
}
level := analysis.ResolutionLevel(resolution)
if !analysis.ValidResolutionLevel(level) {
return nil, "get_architecture: unknown resolution " + resolution +
" (expected: symbol, file, package, service, system)"
}
view := analysis.BuildHierarchy(g, level, cr)
nodes := make([]map[string]any, 0, len(view.Nodes))
for _, n := range view.Nodes {
nodes = append(nodes, map[string]any{
"id": n.ID,
"label": n.Label,
"leaf_count": n.LeafCount,
})
}
edges := make([]map[string]any, 0, len(view.Edges))
for _, e := range view.Edges {
edges = append(edges, map[string]any{
"from": e.From,
"to": e.To,
"weight": e.Weight,
})
}
return map[string]any{
"level": string(view.Level),
"node_count": len(view.Nodes),
"edge_count": len(view.Edges),
"leaf_count": view.LeafCount,
"nodes": nodes,
"edges": edges,
"self_loops": view.SelfLoops,
}, ""
}
// architectureSummary builds the language mix + node/edge count
// header. Edges are bounded to the scoped subgraph so multi-repo
// callers don't see cross-workspace numbers. nil inScope is the
// signal that every node is in scope — the helper short-circuits
// the lang count through Stats() and the edge count through
// EdgeCount() rather than materialising the whole graph over cgo.
func architectureSummary(allScoped []*graph.Node, inScope map[string]bool, totalNodes int, g graph.Store) map[string]any {
langCounts := map[string]int{}
if inScope == nil {
// Unbound session + no path-prefix — pull the aggregate from
// the backend's cached stats. One indexed groupby vs a
// whole-table scan over cgo.
stats := g.Stats()
maps.Copy(langCounts, stats.ByLanguage)
} else {
for _, n := range allScoped {
if !inScope[n.ID] || n.Language == "" {
continue
}
langCounts[n.Language]++
}
}
type langRow struct {
Name string `json:"name"`
Nodes int `json:"nodes"`
}
var languages []langRow
for name, n := range langCounts {
languages = append(languages, langRow{Name: name, Nodes: n})
}
sort.Slice(languages, func(i, j int) bool {
if languages[i].Nodes != languages[j].Nodes {
return languages[i].Nodes > languages[j].Nodes
}
return languages[i].Name < languages[j].Name
})
// Common case — unbound session + no path-prefix — every node
// is in scope so the edge count is exactly the backend's
// EdgeCount(), which is an O(1) lookup. Skips materialising
// every edge over cgo just to count them.
var totalEdges int
if inScope == nil {
totalEdges = g.EdgeCount()
} else {
for _, e := range g.AllEdges() {
if !inScope[e.From] {
continue
}
if !inScope[e.To] {
continue
}
totalEdges++
}
}
primary := ""
if len(languages) > 0 {
primary = languages[0].Name
}
unscopedCount := totalNodes
if inScope != nil {
unscopedCount = len(allScoped)
}
return map[string]any{
"total_nodes": totalNodes,
"total_nodes_unscoped": unscopedCount,
"total_edges": totalEdges,
"primary_language": primary,
"languages": languages,
}
}
func architectureCommunities(cr *analysis.CommunityResult, inScope map[string]bool, top int) map[string]any {
out := map[string]any{"count": 0}
if cr == nil {
return out
}
kept := make([]analysis.Community, 0, len(cr.Communities))
for _, c := range cr.Communities {
// nil inScope means "every node is in scope" — keep the
// community unconditionally. Otherwise drop the community
// when no member lands inside the session's workspace.
if inScope != nil {
match := false
for _, m := range c.Members {
if inScope[m] {
match = true
break
}
}
if !match {
continue
}
}
kept = append(kept, c)
}
sort.Slice(kept, func(i, j int) bool {
return kept[i].Size > kept[j].Size
})
pruned := kept
if len(pruned) > top {
pruned = pruned[:top]
}
rows := make([]map[string]any, 0, len(pruned))
for _, c := range pruned {
rows = append(rows, map[string]any{
"id": c.ID,
"label": c.Label,
"hub": c.Hub,
"size": c.Size,
"cohesion": c.Cohesion,
"files": c.Files,
})
}
out["count"] = len(kept)
out["modularity"] = cr.Modularity
out["top"] = rows
return out
}
func architectureHotspots(hotspots []analysis.HotspotEntry, inScope map[string]bool, top int) []map[string]any {
out := []map[string]any{}
for _, h := range hotspots {
if len(out) >= top {
break
}
if inScope != nil && !inScope[h.ID] {
continue
}
out = append(out, map[string]any{
"id": h.ID,
"name": h.Name,
"kind": h.Kind,
"file_path": h.FilePath,
"fan_in": h.FanIn,
"fan_out": h.FanOut,
"betweenness": h.Betweenness,
"complexity_score": h.ComplexityScore,
})
}
return out
}
// architectureEntryPoints returns functions/methods with zero
// incoming edges and at least one outgoing edge — the "called by
// no one, calls into the system" pattern.
//
// The candidate pool is either the kind-filtered subset of an in-scope
// node map (bound session / path-prefix narrowing) or — when inScope
// is nil — the function+method slice pulled directly from the storage
// layer via NodesByKindsScanner. The legacy code path walked the full
// scoped-nodes slice every call just to keep the callable subset.
//
// Uses NodeDegreeAggregator when the backend implements it (one
// batched in/out count instead of 2N GetInEdges/GetOutEdges
// round-trips on a disk backend — the per-node loop was the entire
// wall-clock cost of this section on large repos).
func architectureEntryPoints(inScope map[string]bool, g graph.Store, top int) []map[string]any {
type entryCandidate struct {
node *graph.Node
fanOut int
}
// Pre-filter on kind Go-side first. When inScope is nil pull
// only function/method via the kind scanner; otherwise project
// the same subset out of the supplied scope set.
var pool []*graph.Node
if inScope == nil {
if scan, ok := g.(graph.NodesByKindsScanner); ok {
pool = scan.NodesByKinds([]graph.NodeKind{graph.KindFunction, graph.KindMethod})
} else {
all := g.AllNodes()
pool = make([]*graph.Node, 0, len(all))
for _, n := range all {
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
pool = append(pool, n)
}
}
}
} else {
// Materialise the callable subset out of the in-scope node
// id set. The caller's scoped slice already lives in memory,
// so this stays cheap — but the inScope map carries bools,
// not nodes, so we re-resolve via GetNode for each id.
pool = make([]*graph.Node, 0, len(inScope))
for id := range inScope {
n := g.GetNode(id)
if n == nil {
continue
}
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
pool = append(pool, n)
}
}
cands := make([]entryCandidate, 0, len(pool))
if agg, ok := g.(graph.NodeDegreeAggregator); ok && len(pool) > 0 {
ids := make([]string, 0, len(pool))
byID := make(map[string]*graph.Node, len(pool))
for _, n := range pool {
ids = append(ids, n.ID)
byID[n.ID] = n
}
for _, r := range agg.NodeDegreeCounts(ids, nil) {
if r.InCount > 0 || r.OutCount == 0 {
continue
}
n := byID[r.NodeID]
if n == nil {
continue
}
cands = append(cands, entryCandidate{node: n, fanOut: r.OutCount})
}
} else {
for _, n := range pool {
if len(g.GetInEdges(n.ID)) > 0 {
continue
}
out := len(g.GetOutEdges(n.ID))
if out == 0 {
continue
}
cands = append(cands, entryCandidate{node: n, fanOut: out})
}
}
sort.Slice(cands, func(i, j int) bool {
if cands[i].fanOut != cands[j].fanOut {
return cands[i].fanOut > cands[j].fanOut
}
return cands[i].node.ID < cands[j].node.ID
})
if len(cands) > top {
cands = cands[:top]
}
out := make([]map[string]any, 0, len(cands))
for _, c := range cands {
out = append(out, map[string]any{
"id": c.node.ID,
"name": c.node.Name,
"file_path": c.node.FilePath,
"fan_out": c.fanOut,
})
}
return out
}
func architectureProcesses(pr *analysis.ProcessResult, inScope map[string]bool, top int) []architectureProcess {
if pr == nil {
return []architectureProcess{}
}
kept := make([]analysis.Process, 0, len(pr.Processes))
for _, p := range pr.Processes {
if inScope != nil && !inScope[p.EntryPoint] {
continue
}
kept = append(kept, p)
}
sort.Slice(kept, func(i, j int) bool {
if kept[i].Score != kept[j].Score {
return kept[i].Score > kept[j].Score
}
return kept[i].StepCount > kept[j].StepCount
})
if len(kept) > top {
kept = kept[:top]
}
out := make([]architectureProcess, 0, len(kept))
for _, p := range kept {
out = append(out, architectureProcess{
ID: p.ID,
Name: p.Name,
EntryPoint: p.EntryPoint,
StepCount: p.StepCount,
Files: p.Files,
Score: p.Score,
})
}
return out
}
// architectureCrossRepo bundles every cross_repo_* edge into a
// (from_repo, to_repo, kind) → count rollup. Empty list when no
// cross-repo edges exist (single-repo mode).
//
// Picks the CrossRepoEdgeAggregator capability when the backend
// implements it (one server-side aggregate replaces the AllEdges +
// per-edge GetNode pair — typically ~286k edge rows + thousands
// of GetNode round-trips on a disk backend for <100 rows of output). Falls
// back to the AllEdges-driven loop on backends that don't.
func architectureCrossRepo(g graph.Store) []crossRepoRow {
type key struct {
kind, fromRepo, toRepo string
}
counts := map[key]int{}
if ag, ok := g.(graph.CrossRepoEdgeAggregator); ok {
for _, r := range ag.CrossRepoEdgeCounts() {
counts[key{kind: string(r.Kind), fromRepo: r.FromRepo, toRepo: r.ToRepo}] = r.Count
}
} else {
for _, e := range g.AllEdges() {
if _, isCross := graph.BaseKindForCrossRepo(e.Kind); !isCross {
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
k := key{kind: string(e.Kind), fromRepo: from.RepoPrefix, toRepo: to.RepoPrefix}
counts[k]++
}
}
rows := make([]crossRepoRow, 0, len(counts))
for k, c := range counts {
rows = append(rows, crossRepoRow{
Kind: k.kind, FromRepo: k.fromRepo, ToRepo: k.toRepo, Count: c,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
if rows[i].FromRepo != rows[j].FromRepo {
return rows[i].FromRepo < rows[j].FromRepo
}
return rows[i].ToRepo < rows[j].ToRepo
})
return rows
}
func architectureContracts(registry *contracts.Registry) map[string]any {
out := map[string]any{
"total": 0,
"by_type": map[string]int{},
"by_role": map[string]int{},
"by_workspace": map[string]int{},
}
if registry == nil {
return out
}
all := registry.All()
byType := map[string]int{}
byRole := map[string]int{}
byWS := map[string]int{}
for _, c := range all {
byType[string(c.Type)]++
byRole[string(c.Role)]++
ws := c.EffectiveWorkspace()
if ws == "" {
ws = "(unscoped)"
}
byWS[ws]++
}
out["total"] = len(all)
out["by_type"] = byType
out["by_role"] = byRole
out["by_workspace"] = byWS
return out
}